最近在给女友写一款月经打卡应用。重构月经周期分析模块时,我对计算所需的数据应该作为对象成员变量,还是在调用方法时传入,有了一些新的想法。
成员变量描述对象
在面向对象中,成员变量描述对象自身,对象拥有成员变量,两者体现的是归属关系。方法参数是方法执行时接收的输入变量,是方法进行计算或行为的输入依据,两者体现的是使用关系。
原本业务中有这样一个需求,我们需要将用户的经期打卡数据进行分组,每组代表一个经期,因此要对经期进行建模。
原始代码
/**
* 一组连续经期打卡所表示的经期。
*/
export class Menstruation {
public constructor(
public readonly startDate: LocalDate,
private readonly lastRecordedDate: LocalDate,
private readonly today: LocalDate,
) {}
public get state(): MenstruationState {
// 计算经期状态
}
public get isTypicalDurationSample(): boolean {
// 计算经期是否可作为估算样本
}
}
其中 startDate 是 该经期的开始日期,lastRecordedDate 是 该经期最后打卡日期,today 是观测日期,用于计算 观测日期下的 经期状态 state 和 是否可作为估算样本 isTypicalDurationSample。
存在问题
经期的开始日期 和 最后打卡日期 语义上属于 经期,但是 观测日期 today 和 经期 并不是归属关系。
实际上,将 today 作为 Menstruation 的成员变量,并不是绝对的错误,但是不符合我们的设计语义,此时 Menstruation 的语义将变为 某个观测日期下的经期快照,state 也将是 该经期快照 的状态,是固定的。
重构思路
today 不应该作为 Menstruation 的成员,而应该作为 状态计算 和 判断是否可作为估算样本 的方法参数。
语义上理解为,经期包含 经期开始日期 和 最后打卡日期,而 经期提供 在某个观测日期下 计算状态 和 判断是否可作为估算样本 的方法。
修改后的代码
/**
* 一组连续经期打卡所表示的经期。
*/
export class Menstruation {
public constructor(
public readonly startDate: LocalDate,
private readonly lastRecordedDate: LocalDate,
) {}
public getState(today: LocalDate): MenstruationState {
// 计算经期状态
}
public isTypicalDurationSample(today: LocalDate): boolean {
// 计算经期是否可作为估算样本
}
}
计算过程与计算结果分别建模
在给对象进行类建模时,应当明确类的语义,不要混淆 计算过程 和 计算结果 这两个语义。比如,状态计算器 和 状态 是两个不同的语义,前者提供状态的计算函数,而后者是状态数据的打包体。
业务中有这样一个需求,我们需要依据 最后一次月经周期 和 通过典型值得到的各阶段时间点预测 来计算 当前观测日期下的 月经周期状态。
原始代码
export class MenstrualCycleStatus {
public constructor(
private readonly latestCycle: MenstrualCycle,
private readonly prediction: MenstrualCyclePredictionTimings,
) {}
public get phase(): InferredMenstrualCyclePhase {
// 通过 latestCycle和prediction计算phase
}
public get warning(): MenstrualCycleWarning | undefined {
// 通过 latestCycle 计算 warning
}
}
其中 latestCycle 是 最后一次月经周期,prediction 是 通过典型值得到的各阶段时间点预测,phase 是 当前观测日期下的 月经周期阶段,warning 是 当前观测日期下的 月经周期提醒。原始实现中,周期对象 和 预测对象 已经保存了观测日期 today,因此这里没有显式接收 today。
存在问题
MenstrualCycleStatus 的类名和 phase、warning 属性表达的是 状态结果,但它保存的 latestCycle 和 prediction 是计算依据。它接收计算依据来计算状态,却又把自身作为状态结果提供给调用方,混合了 状态计算器 和 状态结果 两种语义。
如果我们要设计一个 MenstrualCycleStatusCalculator 应该这样设计:
export class MenstrualCycleStatusCalculator {
public calculate(
latestCycle: MenstrualCycle,
prediction: MenstrualCyclePredictionTimings,
today: LocalDate,
): MenstrualCycleStatus {
// 前面重构时,MenstrualCycle 也做了与 Menstruation 相同的调整,不再保存 today,
// 因此这里需要传入观测日期,以查询该日期下的周期状态。
// 计算月经周期状态
}
}
此时也不会把 最后一次月经周期 和 预测 作为 计算器的成员,因为 计算器 的成员应当是 描述 计算器的,我们也不会用 getter 来返回和状态有关的值,因为 getter 应当用作派生属性,而这些状态相关的值不是 计算器 的属性。
重构思路
我们按上述分析,对 状态计算器 和 状态结果 分别建模,考虑到 状态计算器 无须成员变量,可以简化为 纯计算函数。而 状态对象 按语义来说只需要包含 阶段 和 提醒,用纯数据包表达即可。
修改后的代码
export function calculateMenstrualCycleStatus(
latestCycle: MenstrualCycle,
prediction: MenstrualCyclePredictionTimings,
today: LocalDate,
): MenstrualCycleStatus {
// MenstrualCycle 也做了与 Menstruation 相同的调整,不再保存 today,
// 因此这里需要传入观测日期,以查询该日期下的周期状态。
// 计算月经周期状态
}
export interface MenstrualCycleStatus {
readonly phase: InferredMenstrualCyclePhase;
readonly warning?: MenstrualCycleWarning;
}