3.2 柯里化与组合子:手推练习课


3.2 柯里化与组合子:手推练习课

本节摘要:柯里化把"一次吃三个参数的函数"拆成"连吃三次、每次一个参数"的函数链;组合子则把现成函数直接焊接成新函数,全程不写中间变量。本节按手推练习课的组织方式展开:每一步变换先在类型层面推导,再落到代码验证,最终用点自由风格重写一个真实的报价逻辑。所有推导都附中间步骤,可拿纸笔跟着走。

一段可以当场验证的演示

不先给定义,先给一段 JavaScript,可以在任何控制台里直接贴:

// 普通的三参数函数 const clip = (text, max, suffix) => text.length > max ? text.slice(0, max) + suffix : text; console.log(clip("函数式编程实战", 4, "…")); // 函数式编程… // 柯里化后:一次一个参数,逐级返回新函数 const curriedClip = (max) => (suffix) => (text) => text.length > max ? text.slice(0, max) + suffix : text; // 参数顺序决定了"能先钉住什么":先钉配置,后喂数据 const clipBrief = curriedClip(30)("…"); // 一个"截断到30字"的专用机 console.log(clipBrief("这是一段很长很长的摘要文本,会被截断")); // 这是一段很长很长的摘要文本,会被截断(长度未超30,原样返回) console.log(curriedClip(4)("…")("函数式编程实战")); // 函数式编程…

curriedClip(4) 返回的是"等一个后缀的函数",再喂 "…" 返回"等正文的函数"。柯里化把一次性的参数列表变成了可分步施工的流水线——每一步都产出一个可独立命名的、更专用的函数。这就是本节要手推的全部现象背后的机制。

一、手推练习一:柯里化的类型变化

练习在 Haskell 里做,因为类型签名会当场告诉你拆装发生了什么。设一个双参数函数:

原始: add :: Int -> Int -> Int

注意 Haskell 的箭头是右结合的,这行签名本来就该读成:

实际含义:add :: Int -> (Int -> Int)

也就是说,Haskell 的所有函数本来就是单参数的add 3 合法,它返回一个"再加多少"的新函数;add 3 4(add 3) 4——先拿 3 换得函数,再拿 4 喂给那个函数。这就是柯里化在 Haskell 里"看不见"的原因:它是默认形态。把这个读法推广到三参数函数:

volume :: Double -> Double -> Double -> Double -- 实为 Double -> (Double -> (Double -> Double)) volume w h d = w * h * d areaOf :: Double -> Double -> Double areaOf w h = volume w h 1.0 -- 钉住深度1.0,得到"算面积"的专用函数 main = do print (volume 2 3 4) -- 24.0 print (areaOf 2 3) -- 6.0:三参函数降维成两参函数,零成本

手推要领写成分步练习:给任意签名 a -> b -> c,第一步按右结合加括号得 a -> (b -> c);第二步读作"喂 a 得到 b 到 c 的函数";第三步问"钉住 a 能造出什么专用函数"——能答出第三步,柯里化就算学会了。Python 没有默认柯里化,需要手工构造,下面的对照版用嵌套 lambda 实现同一件事:

# Python 对照:手工柯里化 volume = lambda w: lambda h: lambda d: w * h * d area_of = volume(1.0) # 钉住宽度1.0(演示用),返回双参函数 print(volume(2)(3)(4)) # 24

参数顺序在柯里化后有了工程含义:越靠前的参数越"配置化",越靠后的参数越"数据化"——数据总是最后喂,配置提前钉死。设计柯里化函数时先排参数:回调、阈值、格式串在前,原始数据在后。这是柯里化最重要的一条设计准则。

二、手推练习二:组合子的焊接术

组合子(combinator)指"不引入自由变量、纯靠参数拼装"的函数。本节聚焦最常用的一个:组合算子,Haskell 里是 .,数学记法圈号。

定义: f . g = \x -> f (g x) 类型: (.) :: (b -> c) -> (a -> b) -> a -> c

类型手推三步:g 先算,产出 b;f 接 b,产出 c;合起来就是 a 到 c。两个函数首尾类型咬合(g 的输出类型等于 f 的输入类型),就能焊接——这是组合的唯一约束,也是编译器帮你检查的合同。落在代码上:

import Data.Char (toLower) -- 两个现成函数 normalize :: String -> String normalize = map toLower stripPunct :: String -> String stripPunct = filter (`notElem` ",.!?;:") -- 焊接:先去标点,再转小写 —— 数据流从右向左 clean :: String -> String clean = normalize . stripPunct main = print (clean "Hello, World!") -- "hello world"

clean = normalize . stripPunct 是"点自由"(point-free)风格:定义里没有出现参数 x,函数由既有函数直接拼成。组合能无限串联,配合柯里化还能焊接多参数函数的部分应用:

-- 柯里化与组合联手:从通用零件拼出业务函数 import Data.List (isPrefixOf) startsWithHttp :: String -> Bool startsWithHttp = isPrefixOf "http://" -- isPrefixOf 钉住第一个参数 -- 组合链:清洗后判断协议前缀 isCleanHttp :: String -> Bool isCleanHttp = startsWithHttp . stripPunct

图:柯里化与组合的积木拼装全景

图:柯里化与组合的积木拼装全景

三、手推练习三:JavaScript 的组合子实现

主流语言没有内建组合算子,实现它只需三行,但实现过程恰好检验你对"函数是一等值"的理解:

// 组合:从右向左 const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x); // 管道:从左向左读更顺时的变体 const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x); const stripPunct = (s) => s.replace(/[,.!?;:]/g, ""); const normalize = (s) => s.toLowerCase(); const words = (s) => s.split(/\s+/).filter(Boolean); const count = (arr) => arr.length; // 组合成业务函数:词数统计 const wordCount = compose(count, words, normalize, stripPunct); // 等价的管道读法(数据流顺序一致,读法从左到右) const wordCountPipe = pipe(stripPunct, normalize, words, count); console.log(wordCount("Hello, Functional. World!")); // 3 console.log(wordCountPipe("Hello, Functional. World!")); // 3

注意 compose 内部用了 reduceRight——本节上一节讲的 reduce 在这里反过来支撑了组合子实现,两大件与组合子的关系是一体的。工程取舍上,pipe 的阅读顺序与数据流一致,团队协作代码里更受推荐;compose 与数学记法一致,适合与文献对照的场景。选一种全库统一,比选哪种本身重要得多。

四、工程现场:用组合子重写报价逻辑

给一个真实形状的需求,演示这套技术的综合运用。规则:基础价超过五百打九折,会员再叠加九八折,最后四舍五入到分。命令式版本:

def final_price(base, is_vip): price = base if base > 500: price = price * 0.9 # 改状态 if is_vip: price = price * 0.98 # 再改状态 return round(price, 2)

组合子版本把每条规则做成独立的"价格变换函数",再用组合焊接:

from functools import reduce def discounted(rate, threshold): return lambda price: price * rate if price > threshold else price def vip_rate(price): return price * 0.98 def to_cents(price): return round(price, 2) # 规则即积木:大额折扣(柯里化钉住参数)、会员折、取整 big_order_discount = discounted(0.9, 500) price_pipeline = lambda base: to_cents(vip_rate(big_order_discount(base))) # 变式:新增"满千减五十"规则,只需再造一个积木插入管道 print(price_pipeline(600)) # 600*0.9*0.98 = 529.2 print(price_pipeline(400)) # 400(未过大额门槛,仍享会员折)392.0

每个积木都是可单测的纯函数,新规则是"造新积木插管道",评审时只需检查新积木本身。代价也要诚实列出:调用栈变深、报错栈不如直白版本直观、团队需要组合子词汇量——抽象税在模块内的分布,从"读逻辑"转移到了"懂积木"。

💡 关键直觉:柯里化是"参数维度的拆装",组合是"函数维度的拼接",两者共同把代码的修改单位从"语句"缩小到"积木"。判断一个代码库函数式成熟度的土办法:新增一条规则时,是改了现有函数体,还是加了一个新函数。

本节要点回顾

  • Haskell 函数天生单参数a -> b -> c 右结合读作 a -> (b -> c),柯里化是默认形态而非语法糖。
  • 参数顺序即施工顺序:柯里化后配置在前、数据在后,专用函数由"钉住配置"免费获得。
  • 组合的唯一约束是类型咬合:g 的输出类型等于 f 的输入类型,编译器替你检查合同。
  • 组合子三行实现:compose 用 reduceRight、pipe 用 reduce,三大件与组合子互为支撑。
  • 修改单位缩小到积木:成熟函数式代码库加规则=加函数,这是重构复盘(下一节)的提前预告。

积木术学完,还差最后一块:递归与折叠如何互译、栈会不会爆——下一节把"循环的最后堡垒"也收编进管道世界观。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U