函数组合成类 / 函数组合成变换
函数组合成类(Combine Functions into Class)把一组操作同一份数据的函数收进一个类;函数组合成变换(Combine Functions into Transform)用一个变换函数一次算出所有派生数据,附在原数据上。 它们解决的痛是:几个函数各自从同一份数据算同样的中间值,改规则要改好几处,新来的人也看不出这几个函数是一组。
老冯维护一个水费系统。每户每月一条读数,记录用水量。水费 2.5 元一吨,每月前 10 吨免税,超出部分按 10% 收税。账单、报表、超额提醒三个模块各自算费用。
"""水费:重构前。"""
RATE = 2.5FREE_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.5FREE_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 一处。三个派生值的关系写在类里:税从应税金额算,应税金额从基础费用算。三个输出函数各剩一行,谁都看得出它们是一组。
另一条路是组合成变换。不建类,写一个函数把派生值一次算好、附在数据上:
"""水费:组合成变换。"""
RATE = 2.5FREE_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 元两条路怎么选?原始数据会被修改时选类:quantity 一改,base_charge 这些属性自动跟着变,而变换出来的字典会留着过时的值。原始数据只读、尤其是从接口拿到就不再动的那种,变换更轻:不用建类,一次算好,到处传。
怎么一步步做
Section titled “怎么一步步做”组合成类:
- 用封装记录(第 13 章)把那份数据变成一个类,先只有字段。
- 把操作这份数据的函数一个一个搬进类里做方法,用搬移函数(第 21 章)。原来的
reading参数变成self。每搬一个跑一次测试。 - 把这些函数里重复的中间计算(这里是基础费用和应税金额)提成类的属性或方法。跑测试。
组合成变换:
- 写一个变换函数,接收原始数据,返回它的副本。返回副本而不是改原数据,否则调用方会被悄悄修改。
- 挑一个派生值,把算它的代码搬进变换函数,作为新字段存在副本上。改所有算这个值的地方,让它们读这个字段。跑测试。
- 对其余派生值重复。
定义与适用场景
Section titled “定义与适用场景”函数组合成类:当一组函数总是操作同一份数据、互相之间还通过中间结果连在一起时,把它们和数据收进一个类,中间结果成为类的属性。函数组合成变换:同样的情况下不建类,而是写一个函数一次性把所有派生值算出来附在数据副本上。
两者都常见于“一条记录,多个派生值”的场景:订单的小计、折扣、税、合计;读数的费用和税;用户的等级、权限、显示名。组合成类是通往面向对象设计的常规入口,组合成变换则在数据管道、函数式风格的代码里更自然。
真实系统里的例子
Section titled “真实系统里的例子”接口返回的数据加工
Section titled “接口返回的数据加工”前端拿到接口返回的原始订单后,各个组件常常各自算“是否逾期”、“剩余天数”、“状态文案”,同一个规则在三个组件里写三遍。把它们收进一个 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 里写一次。
什么时候别用
Section titled “什么时候别用”函数们只是碰巧接收同一个参数、互相之间没有共享的中间计算,就没必要硬收进一个类。变换的副本在数据很大、变换很频繁时有开销,这时类的按需计算更合适。
- 几个函数都从同一份数据算同样的中间值,把它们收到一起,中间值只算一次、只写一处。
- 数据会变就组合成类,派生值随数据自动更新;数据只读就组合成变换,一次算好到处传。
- 变换函数返回副本,不改原数据。
- 两条路都从“把一个派生值搬进去”开始,一次搬一个,每次跑测试。