跳转到内容

函数组合成类 / 函数组合成变换

函数组合成类(Combine Functions into Class)把一组操作同一份数据的函数收进一个类;函数组合成变换(Combine Functions into Transform)用一个变换函数一次算出所有派生数据,附在原数据上。 它们解决的痛是:几个函数各自从同一份数据算同样的中间值,改规则要改好几处,新来的人也看不出这几个函数是一组。

老冯维护一个水费系统。每户每月一条读数,记录用水量。水费 2.5 元一吨,每月前 10 吨免税,超出部分按 10% 收税。账单、报表、超额提醒三个模块各自算费用。

"""水费:重构前。"""
RATE = 2.5
FREE_QUOTA = 10
def bill_line(reading: dict) -> str:
return f"账单:{reading['quantity'] * RATE} 元"
def taxable_line(reading: dict) -> str:
taxable = max(0.0, reading["quantity"] * RATE - FREE_QUOTA * RATE)
return f"应税:{taxable} 元"
def tax_line(reading: dict) -> str:
taxable = max(0.0, reading["quantity"] * RATE - FREE_QUOTA * RATE)
return f"税:{taxable * 0.1} 元"
READING = {"customer": "3 栋 201", "month": 9, "quantity": 18}
if __name__ == "__main__":
print(bill_line(READING))
print(taxable_line(READING))
print(tax_line(READING))
# 输出:
# 账单:45.0 元
# 应税:20.0 元
# 税:2.0 元
// 水费:重构前。
export interface Reading {
customer: string;
month: number;
quantity: number;
}
const RATE = 2.5;
const FREE_QUOTA = 10;
export function billLine(reading: Reading): string {
return `账单:${reading.quantity * RATE} 元`;
}
export function taxableLine(reading: Reading): string {
const taxable = Math.max(0, reading.quantity * RATE - FREE_QUOTA * RATE);
return `应税:${taxable} 元`;
}
export function taxLine(reading: Reading): string {
const taxable = Math.max(0, reading.quantity * RATE - FREE_QUOTA * RATE);
return `税:${taxable * 0.1} 元`;
}
export const READING: Reading = { customer: "3 栋 201", month: 9, quantity: 18 };
console.log(billLine(READING));
console.log(taxableLine(READING));
console.log(taxLine(READING));
// 输出:
// 账单:45 元
// 应税:20 元
// 税:2 元

如果现在要加一个需求:夏季(6 到 9 月)免税额度提高到 15 吨。“应税金额”的算法要改,而它出现在 taxable_line 和 tax_line 两处,漏改一处账单和税单就对不上。三个函数都在用 reading["quantity"] * RATE 算基础费用,水价调整也是三处。

另一个问题是这三个函数只是碰巧放在一起。它们都围绕“一条读数”工作,但没有任何结构说明这一点,新同事要自己发现它们是一组、taxable 是从 base_charge 算出来的。这是重复代码,也是依恋情结的苗头:三个函数对 reading 的内部结构了如指掌,却不属于它。

两条路,先看组合成类:

"""水费:组合成类。"""
RATE = 2.5
FREE_QUOTA = 10
class Reading:
def __init__(self, data: dict) -> None:
self.customer = data["customer"]
self.month = data["month"]
self.quantity = data["quantity"]
@property
def base_charge(self) -> float:
return self.quantity * RATE
@property
def taxable_charge(self) -> float:
return max(0.0, self.base_charge - FREE_QUOTA * RATE)
@property
def tax(self) -> float:
return self.taxable_charge * 0.1
def bill_line(reading: dict) -> str:
return f"账单:{Reading(reading).base_charge} 元"
def taxable_line(reading: dict) -> str:
return f"应税:{Reading(reading).taxable_charge} 元"
def tax_line(reading: dict) -> str:
return f"税:{Reading(reading).tax} 元"
READING = {"customer": "3 栋 201", "month": 9, "quantity": 18}
if __name__ == "__main__":
print(bill_line(READING))
print(taxable_line(READING))
print(tax_line(READING))
# 输出:
# 账单:45.0 元
# 应税:20.0 元
# 税:2.0 元
// 水费:组合成类。
export interface ReadingData {
customer: string;
month: number;
quantity: number;
}
const RATE = 2.5;
const FREE_QUOTA = 10;
class Reading {
readonly customer: string;
readonly month: number;
readonly quantity: number;
constructor(data: ReadingData) {
this.customer = data.customer;
this.month = data.month;
this.quantity = data.quantity;
}
get baseCharge(): number {
return this.quantity * RATE;
}
get taxableCharge(): number {
return Math.max(0, this.baseCharge - FREE_QUOTA * RATE);
}
get tax(): number {
return this.taxableCharge * 0.1;
}
}
export function billLine(reading: ReadingData): string {
return `账单:${new Reading(reading).baseCharge} 元`;
}
export function taxableLine(reading: ReadingData): string {
return `应税:${new Reading(reading).taxableCharge} 元`;
}
export function taxLine(reading: ReadingData): string {
return `税:${new Reading(reading).tax} 元`;
}
export const READING: ReadingData = { customer: "3 栋 201", month: 9, quantity: 18 };
console.log(billLine(READING));
console.log(taxableLine(READING));
console.log(taxLine(READING));
// 输出:
// 账单:45 元
// 应税:20 元
// 税:2 元

夏季额度那个需求现在只改 taxable_charge 一处。三个派生值的关系写在类里:税从应税金额算,应税金额从基础费用算。三个输出函数各剩一行,谁都看得出它们是一组。

组合成类之后的结构:三个派生值是 Reading 的方法,三个输出函数各自只调用其中一个

另一条路是组合成变换。不建类,写一个函数把派生值一次算好、附在数据上:

"""水费:组合成变换。"""
RATE = 2.5
FREE_QUOTA = 10
def enrich_reading(reading: dict) -> dict:
result = dict(reading)
result["base_charge"] = result["quantity"] * RATE
result["taxable_charge"] = max(0.0, result["base_charge"] - FREE_QUOTA * RATE)
result["tax"] = result["taxable_charge"] * 0.1
return result
def bill_line(reading: dict) -> str:
return f"账单:{reading['base_charge']} 元"
def taxable_line(reading: dict) -> str:
return f"应税:{reading['taxable_charge']} 元"
def tax_line(reading: dict) -> str:
return f"税:{reading['tax']} 元"
READING = {"customer": "3 栋 201", "month": 9, "quantity": 18}
if __name__ == "__main__":
enriched = enrich_reading(READING)
print(bill_line(enriched))
print(taxable_line(enriched))
print(tax_line(enriched))
# 输出:
# 账单:45.0 元
# 应税:20.0 元
# 税:2.0 元
// 水费:组合成变换。
export interface Reading {
customer: string;
month: number;
quantity: number;
}
export interface EnrichedReading extends Reading {
baseCharge: number;
taxableCharge: number;
tax: number;
}
const RATE = 2.5;
const FREE_QUOTA = 10;
export function enrichReading(reading: Reading): EnrichedReading {
const baseCharge = reading.quantity * RATE;
const taxableCharge = Math.max(0, baseCharge - FREE_QUOTA * RATE);
return { ...reading, baseCharge, taxableCharge, tax: taxableCharge * 0.1 };
}
export function billLine(reading: EnrichedReading): string {
return `账单:${reading.baseCharge} 元`;
}
export function taxableLine(reading: EnrichedReading): string {
return `应税:${reading.taxableCharge} 元`;
}
export function taxLine(reading: EnrichedReading): string {
return `税:${reading.tax} 元`;
}
export const READING: Reading = { customer: "3 栋 201", month: 9, quantity: 18 };
const enriched = enrichReading(READING);
console.log(billLine(enriched));
console.log(taxableLine(enriched));
console.log(taxLine(enriched));
// 输出:
// 账单:45 元
// 应税:20 元
// 税:2 元

组合成变换:原始读数经 enrich_reading 得到带派生字段的副本,三个输出函数只读字段

两条路怎么选?原始数据会被修改时选类:quantity 一改,base_charge 这些属性自动跟着变,而变换出来的字典会留着过时的值。原始数据只读、尤其是从接口拿到就不再动的那种,变换更轻:不用建类,一次算好,到处传。

组合成类:

  1. 用封装记录(第 13 章)把那份数据变成一个类,先只有字段。
  2. 把操作这份数据的函数一个一个搬进类里做方法,用搬移函数(第 21 章)。原来的 reading 参数变成 self。每搬一个跑一次测试。
  3. 把这些函数里重复的中间计算(这里是基础费用和应税金额)提成类的属性或方法。跑测试。

组合成变换:

  1. 写一个变换函数,接收原始数据,返回它的副本。返回副本而不是改原数据,否则调用方会被悄悄修改。
  2. 挑一个派生值,把算它的代码搬进变换函数,作为新字段存在副本上。改所有算这个值的地方,让它们读这个字段。跑测试。
  3. 对其余派生值重复。

函数组合成类:当一组函数总是操作同一份数据、互相之间还通过中间结果连在一起时,把它们和数据收进一个类,中间结果成为类的属性。函数组合成变换:同样的情况下不建类,而是写一个函数一次性把所有派生值算出来附在数据副本上。

两者都常见于“一条记录,多个派生值”的场景:订单的小计、折扣、税、合计;读数的费用和税;用户的等级、权限、显示名。组合成类是通往面向对象设计的常规入口,组合成变换则在数据管道、函数式风格的代码里更自然。

前端拿到接口返回的原始订单后,各个组件常常各自算“是否逾期”、“剩余天数”、“状态文案”,同一个规则在三个组件里写三遍。把它们收进一个 enrichOrder 变换,组件只读字段。

// 模拟前端对接口数据的加工:一次算好派生字段。
interface RawOrder {
id: number;
dueDate: string;
status: "open" | "paid";
}
interface Order extends RawOrder {
overdue: boolean;
daysLeft: number;
label: string;
}
function enrichOrder(raw: RawOrder, today: string): Order {
const daysLeft = Math.round((Date.parse(raw.dueDate) - Date.parse(today)) / 86_400_000);
const overdue = raw.status === "open" && daysLeft < 0;
const label = raw.status === "paid" ? "已付款" : overdue ? `逾期 ${-daysLeft} 天` : `还有 ${daysLeft} 天`;
return { ...raw, daysLeft, overdue, label };
}
const today = "2026-10-07";
const orders = [
{ id: 1, dueDate: "2026-10-10", status: "open" as const },
{ id: 2, dueDate: "2026-10-01", status: "open" as const },
{ id: 3, dueDate: "2026-10-01", status: "paid" as const },
].map((raw) => enrichOrder(raw, today));
for (const order of orders) console.log(order.id, order.label, order.overdue);
// 输出:
// 1 还有 3 天 false
// 2 逾期 6 天 true
// 3 已付款 false

列表、详情、提醒三个组件都读 order.label 和 order.overdue,“逾期”的定义只在 enrichOrder 里写一次。

函数们只是碰巧接收同一个参数、互相之间没有共享的中间计算,就没必要硬收进一个类。变换的副本在数据很大、变换很频繁时有开销,这时类的按需计算更合适。

  • 几个函数都从同一份数据算同样的中间值,把它们收到一起,中间值只算一次、只写一处。
  • 数据会变就组合成类,派生值随数据自动更新;数据只读就组合成变换,一次算好到处传。
  • 变换函数返回副本,不改原数据。
  • 两条路都从“把一个派生值搬进去”开始,一次搬一个,每次跑测试。