返回笔记索引

Notes

成员变量与方法参数的取舍

从一次月经周期分析模块的重构出发,讨论成员变量与方法参数的取舍,以及计算过程和结果对象的区分。

最近在给女友写一款月经打卡应用。重构月经周期分析模块时,我对计算所需的数据应该作为对象成员变量,还是在调用方法时传入,有了一些新的想法。


成员变量描述对象

在面向对象中,成员变量描述对象自身,对象拥有成员变量,两者体现的是归属关系。方法参数是方法执行时接收的输入变量,是方法进行计算或行为的输入依据,两者体现的是使用关系。

原本业务中有这样一个需求,我们需要将用户的经期打卡数据进行分组,每组代表一个经期,因此要对经期进行建模。

原始代码

/**
 * 一组连续经期打卡所表示的经期。
 */
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 的类名和 phasewarning 属性表达的是 状态结果,但它保存的 latestCycleprediction 是计算依据。它接收计算依据来计算状态,却又把自身作为状态结果提供给调用方,混合了 状态计算器 和 状态结果 两种语义。

如果我们要设计一个 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;
}