You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
constcurry=fn=>{if(typeoffn!=='function'){thrownewError('no function provided!')}// 因为要递归,使用箭头函数会不方便returnfunctioncurriedFn(...args){// 递归出口放在前面,更加好理解if(args.length===fn.length){returnfn(...args)}// 箭头函数没有 arguments 需要显示给出参数return(...params)=>{returncurriedFn(...args.concat(params))}}}
柯里化如何改善代码
有一个日志函数:
constloggerHelper=(mode,initialMessage,errorMessage,lineNO)=>{switch(mode){case'DEBUG':
console.debug(initialMessage,`${errorMessage} at line ${lineNO}`)breakcase'ERROR':
console.error(initialMessage,`${errorMessage} at line ${lineNO}`)breakcase'WARN':
console.warn(initialMessage,`${errorMessage} at line ${lineNO}`)breakdefault:
thrownewError('Wrong mode!')}}// 习惯样调用loggerHelper('ERROR','Error at index.js','报错了',10)loggerHelper('ERROR','Error at main.js','报错了',13)
还能改善吗?
以上调用在传递很多重复参数,可以使用柯里化改善。
::: tip 柯里化帮助去除重复参数和样板代码
consterrorLog=curry(loggerHelper)('ERROR')errorLog('Error at index.js','报错了',10)errorLog('Error at index.js')('报错了',10)errorLog('Error at index.js')('报错了')(13)// 柯里化时传递所有参数,得到最后的调用结果curry(loggerHelper)('ERROR','Error at main.js','报错了',130)// 部分应用所有参数,需要再调用一次函数才能得到结果consterrorLog2=partialLeft(loggerHelper,'ERROR','Error at index.js','报错了',130)errorLog2()
constsplitSentence=str=>str.split(' ')constcount=array=>array.lengthconststr=`hello function programming`constwordCount=compose(count,splitSentence)(str)
constoddOrEven=count=>(count%2===0 ? 'even' : 'odd')constoddOrEvenWords=compose(oddOrEven,count,splitSentence)conststr=`hello function programming react`console.log(oddOrEvenWords(str))// even
constisOdd=str=>str==='odd'constisOddWords=pipe(splitSentence,pipe(count,oddOrEven),isOdd)conststr=`hello function programming react`console.log(isOddWords(str))// false
函数式编程概述
函数式编程倡导使用数学中的函数思想指导编程语言中函数的编写,使用函数构建程序。其背后的哲学思想是程序的本质是计算,而函数是计算的严密的精确化的表达。函数是编程关注做什么,而如何做都抽象到函数中,通过函数屏蔽了如何做的复杂性。函数式编程让能程序更健壮、易测试、易扩展和复用、随意并发等优点。
编程范式
编程范式,编程方式或者编程原则,代表着编程语言或者编程语言的设计者或者使用者对程序的基本看法。学习一门新语言式时,从编程范式去理解它的设计思想,对写好代码有很好的帮助,它更加高屋建瓴。
编程范式一般包括三个方面,以 OOP 为例:
学科的逻辑体系——规则范式:如类/对象、继承、动态绑定、方法改写、对象替换等等机制。
心理认知因素——心理范式:按照面向对象编程之父 Alan Kay 的观点,“计算就是模拟”。OO 范式极其重视隐喻(metaphor)的价值,通过拟人化,按照自然的方式模拟自然。
自然观/世界观——观念范式:强调程序的组织技术,视程序为松散耦合的对象/类的集合,以继承机制将类组织成一个层次结构,把程序运行视为相互服务的对象们之间的对话。
简单的说,编程范式是程序员看待程序应该具有的观点。
不同的编程范式代表不同的世界观,使用不同的编程范式编程,代码的组织方式、可读性、测试难度,也会不同。
有的语言只主要支持一种编程范式,比如
Haskell
只是支持函数式编程,C 只支持过程式编程,有的语言支持多种,比如 JavaScript、 C++ 支持过程式、面向对象、函数式等,是多范式语言。非结构化编程 (Non-structured programming)
最早期的编程范式,利用
goto
或者label
语句在代码块之间跳转,比如汇编、早期的 BASIC 等,经过计算机科学的不懈探索,发现 goto 语句有害,代码非常难以阅读和推理,这种编程范式基本不再使用。结构化编程 (structured programming)
计算机科学发现 goto 语句的有害后,证明了只需要
顺序语句
、循环语句
、条件语句
就能完成编程,结构化编程就此诞生。计算机科学们对编程语言是否废除goto
有不少争论,有人认为 goto 能使得程序清晰,有人认为使得程序不清晰,现在的流行的编程语言来看,主张废除一派占据上风,广大变程序员已经适应了没有 goto 语句的代码。本人认为 goto 确实使得程序难以理解。广泛使用的编程语言都是结构化编程语言。
命令式编程
使用语句改变程序状态的编程范式。需要一步一步告诉计算机如何执行代码。
支持这一范式的编程语言有:C、C++、Java、JavaScript、Python 等
结构化编程又可以细分为面向对象、面向过程、面向切面等编程范式。
可以说命令式编程是程序状态或者
副作用
驱动的,根据状态执行代码。面向对象
面向对象编程认为,任何一个事物都可抽象成一个对象,用数据(属性)表示对象的状态,用方法表示对数据的操作,对象是数据和行为的结合。
把具有相同或者相似的对象组合,抽象成类或者类别,比如自行车有 2 个轮子、轿车有 4 个轮子,但是他们都是交通交通工具,就可抽象一个交通工具的类统一管理这些车,需要某个类别的车,由类创建即可,当然,你也可以把自行车、汽车分成两个类,它们可继承交通工具类的一些属性。
类是对象的模板。
这是
基于类的面向对象
。支持面向对象的语言有 Java、C++、JavaScript 等。有人认为不需要类来抽象对象,而是需要对象的原型,这就是基于原型的面对对象。JavaScript 支持这种编程范式。
面对对象的编程范式取得巨大成功,很多大规模软件都使用这种编程范式开发。
面向过程
数据和处理的数据的函数分开。比如 C 。
面向切面
根据分离关注点的原则,可把面向对象分为
面向切面
、面向角色
、面向学科
编程。声明式编程
与命令式编程相反,声明编程主张告诉计算机
做什么
,而不是指定如何做
。比如
SQL
、scheme
、正则
等语言。比如声明式,forEach 更加好理解,也不容易出错。
声明式编程的基础是形式逻辑,具有容易推理、容易测试等特点,缺点就是不容易写出好代码,可能和人的天生反逻辑有关,否则我们都是数学家了。
声明式编程可细分为
函数式编程
、逻辑式编程
。函数式编程
是一种古老的编程范式,在冯诺依曼计算诞生之前,就已经存在了,因为数学家们研究计算已经很久了。它的理论基础是
拉姆达演算
。命令式编程式副作用或者状态的改变驱动的,而函数式编程主张
无状态
,是函数演算驱动的,它认为程序的本质式计算,而数学函数正好可以完成这些计算。具有的特点:
无状态: 函数不维护状态。面对对象里,对象具有状态
数据不可变:输入数据不可改变,改变了就要返回新的数据
函数一等值
带有闭包
尾递归
无控制流
可作为参数
可最为返回值
可赋值给变量
仅满足第一条,叫作二等值;3 条都不满足,叫三等值。 数字、字符串、布尔值等常用的值在很多编程语言中都是一等值。
纯函数式
, 即仅支持函数式编程的语言里,是没有变量的,故第三条可理解为可赋值给常量
,比如Haskell
。具有的优点:
代码容易理解
很容易修改
容易并行执行或者异步执行
容易推理,就很容易测试
劣势:
门槛比较高,写好函数式代码很难;
数据复制频繁,消耗内存。
泛型编程
泛型为程语言提供了更高层级的抽象,即参数化类型。换句话说,就是把一个原本特定于某个类型的算法或类当中的类型信息抽象出来。泛型编程提供了更高的抽象层次,这意味着更强的表达能力。
很多面向对象的语言都支持泛型。
其他常用的编程范式
程序的流程由诸如用户操作(鼠标单击、按键)、传感器输出或从其他程序或线程传递的消息等事件决定。通过有一个主循环在监听事件的触发,然后执行回调函数。
很多高级语言都支持。
程序语句描述要匹配的数据和所需的处理,而不是定义要采取的一系列步骤。数据驱动编程类似于事件驱动编程,两者都被构造为模式匹配和结果处理,并且通常由主循环实现,尽管它们通常应用于不同的领域。
数据驱动和其他编程范式结合,可得到数据驱动的设计。react、vue、angular 三大前端框架,都主张数据驱动视图,是一个编程范式的体现。
程序的操作是数据流动。这种编程范式具有函数式编程的一些特征。
命令式编程由分支结构、循环结构、顺序结构就能完成了,但是数据是静止的,比如面对对象编程中,对象之间的数据封装在内部的,不会轻易被外部改变。
相比之下,数据流编程强调数据的移动,让数据的输入输出连接程序操作,有点像数据驱动。程序不共享状态,天然支持并行。
数据流编程可细分,
基于流编程
和响应式编程
,响应式编程用的比较多。响应式编程(reactive programming):使用数据流和变化驱动程序操作。
还需要了解更多。
参考
函数式编程(Functional programming)
编程范式
编程范式,程序员的编程世界观
数学中的函数
函数表达式:
一个输入 X (可以是一个数,可以是一个坐标,获取其他式子),经过变换 F 或者映射 F,得到输出 Y。当 F 满足确定一个 X,就唯一有一个 Y 时,就叫变换 F 叫函数。
只要确定 X,就能唯一确定 Y,如果编程语言的函数也能按照这个性质编写,再把这些函数组成程序,那么程序就非常容易预测,测试起来就非常简单了。
函数式编程正是这种思想指导下的编程方式。这种编程思想在计算机诞生之前,就已经存在了。计算一直被数学家、逻辑学家和哲学家研究、使用,而计算机的发明,让计算更加高效、准确和快速。
函数式中的函数
按照数学中的函数编写的函数:仅依赖输入或者参数就能完成自身逻辑的函数。函数的依赖仅有参数,这保证了相同的参数多次调用函数,返回值相同。这样函数叫纯函数,纯函数没有副作用。
JS 中,函数是可被调用的变量,和其他变量一样,函数可作为参数和返回值。
方法,必须和一个特定的对象绑定,通过该对象调用的函数。
副作用
纯函数内部不会
改变系统状态
,当函数能改变系统状态时,就说这个函数具有副作用。可理解成希望执行某个操作,也顺带执行其他操作,这个其他操作就是副作用。相同的参数,第一次调用和第二次的结果不同,往往是因为函数产生副作用。
slice
是纯的,splice
是不纯的。函数式编程中,希望消除副作用。
再来一个例子:
这种依赖状态是影响系统复杂度的罪魁祸首。输入值之外的因素能够影响返回值,不仅让它变得不纯,而且导致每次我们思考整个软件的时候都痛苦不堪。
试想,当你看到 checkAge1 时,给它一个参数,你要知道外部变量才能预测它的结果,你可能又在项目里到处寻找这是外部依赖是什么,要是需要修改,你可能要考虑想改两个地方。
而 checkAge2 就不存在这个问题。
可以让 minimum 成为一个不可变(immutable)对象:
这让我想起,动车供电系统的设计:
图中 v 字形的装置叫
受电弓
,随车移动,可折叠取回车内,其上方和电线接触的那个横条和高压电线接触。电线和受电弓上横条长期高速摩擦,肯定会有损耗,极端的情况就是电线被摩断了,那么就可能导致列车减速甚至停止运行,但是我们从来没听说过这样的事故,这是为何?
不得不提及受电弓的巧妙设计 ---- 上方和电线接触的横条是
石墨
,导电好,但是硬度不及电线,因此石墨会先被磨损坏掉。一辆列车上有很多受电弓,一个坏了,不会给列车造成很多的影响,这和软件里的
容灾设计
异曲同工。受电弓坏了,列车到站修复,马上上路,要是电线那点断了,要到深山老林去检测那点坏了,理论上电线上每个点都可能坏,动辄几千公里的电线,高压还有列车行驶,无疑有很大的安全隐患。(实际上检查电线时不会一公里一公里地检查,但是了解极端情况就好。)
而让受电弓先磨损,就很方便停车时修理了,工程设计中这叫
集中伤害
,为了能集中检测
、集中修理
。而软件设计中,扩展功能时,修改的地方越少越好,
低耦合
的体现,需要修改的代码越集中,越好,高内聚
的体现,高内聚低耦合
和集中伤害集中修理
有异曲同工之妙。::: tip 副作用
参与计算的代码单元(变量、函数调用、包块、语句等)对整个表单式的影响,仅限于代码单元的返回值,就认为该代码单元没有副作用(side effect),反之,就认为它有副作用。
:::
::: warning 分析
c++
, 参与计算 d ,然后自增 1,会影响后面 c 参与的计算;a+b
, 这个代码单元不会影响后续的计算,即把a+b
的结果使用一个数值代替,不会有任何差别。:::
常见的副作用:
可变数据
;等等。
无副作用,还可以编程吗?函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。
副作用让函数
不纯
,纯函数必须要能够根据相同的输入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了。纯函数的好处
数学中的函数,相同的输入,不管计算多少遍,都得到相同结果。比如
sin(x)
,sin(π/6)
,不管你求多少次,都是0.5
。相同的参数,函数执行执行多次,返回值都相同,这种特性叫
引用透明(referential transparency)
,引用透明的函数的依赖仅有参数。一个例子:
[中国的首都]比老鼠大 是真;
[(116.46E,39.92N)的城市]比老鼠大 是真;
把
北京
替换成中国的首都
、北京的经纬度
,都不改变语义。这些命题的真正含义不会因为我们选择不同的引用或指称名称而受到影响。我们就可以认为场景北京比老鼠大
是引用透明的。与引用透明相反,蒯因认为存在引用不透明( REFERENTIALLY OPAQUE),名称的改变对于整个语句意思非常重要。
比如:
北京有2个字符
是真。那么:中国的首都有 2 个字符 是假。这样场景
xx有2个字符
的含义因为名称指称(name/reference)不同而不同。::: tip 引用透明
个人认为,引用透明翻译得不好,
指代等价
或者替换等价
更加容易理解,也更加契合数学的等价。数学中的函数是引用透明的。
:::
sin(π/6)
和0.5
是等价的,sin(π/6) 参与计算的式子中,可直接使用 0.5 替换,这叫替换模型(substitution model)
,如果一个函数是引用透明的,就可直接使用的返回值替换函数执行过程。这使得并发、缓存、预测等轻而易举。::: tip 纯函数的另一种定义
满足两个条件:
① 无副作用;
② 返回值仅依赖于参数的,即相同输入,必须得到相同的输出。
:::
数学函数相同的输入必定得到相同的输出,这很自然,但是程序中的函数的输出可能依赖隐藏的状态,比如当前时间。
既然纯函数对于相同的输入,都得到相同的返回,那么参数第一次执行的结果,都可缓存起来,下次在调遇到相同的参数,从缓存中获取结果。
纯函数是完全自给自足的,它仅依赖参数。移植方便。
因为函数式编程让开发者只关注
做什么
,没有如何做的细枝末节,具由很强的自文档化。再来一个例子:
命令式编程中“典型”的方法和过程都深深地根植于它们所在的环境中,通过状态、依赖和有效作用(available effects)达成;
vue2 的 options API ,方法、状态都依赖组件的 this,使得组件之间共享逻辑非常困难,vue3 转到 composition API 这个问题就减轻很多,我们可以复用函数来实现共享逻辑。
Erlang 语言的作者 Joe Armstrong 说的这句话:“面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩...以及整个丛林”。
只需简单地给函数一个输入,然后断言输出就好了。
QuickCheck ---- 纯函数测试工具,JS 版本不受欢迎。
纯函数的执行结果,可使用它的返回只替换,和符合人类的直觉。
因为纯函数根本不需要访问共享的依赖,比如内存、全局变量等,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。
并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。
如何编写纯函数
前面对纯函数的定义,都是结果导向的,无法指导程序员编写出纯函数。也就是说,判断一个函数是否为纯函数,还需要可操作的方式。
满足这两个条件,一定为纯函数:
::: tip 纯函数
① 不修改外部变量;
② 不读取外部变量,仅依赖参数。
:::
限制第一条,是为了不产生副作用。这个条件过于严格,其实,只要保证满足第二条约束,即使被修改的变量不被自己和其他代码读取,修改的变量为
只写
变量,即使函数修改了外部变量,副作用就不对程序产生影响。限制第二条,是为了保护子不受到外部变量影响,仅依赖参数才能保证相同的输入得到相同的输出。
依据以上分析,JS 编写纯函数时,需要遵循的规则:
不改变参数,参数为对象和数组时,是可变的。
必须有返回值。
没返回值是副作用驱动的特征。
JS 不是专门的函数式编程语言,完全消除副作用是困难的,也是不必要的。换句话说,要限制副作用仅限于函数内部。
JS 的很多操作和内置函数都是具有副作用的。
不改变参数,复制参数,会有性能损耗,尤其是频繁调用的函数,所以以下情况,允许改变参数:
根据参数返回一个稍微修改的对象或者数组。
递归调用。
不变性
函数式编程强调数据不变性(immutability)。数据不能改变,只能被替换,就具有不变形。js 中基本数据类型都是不变的,数组、对象和函数是可变的。
使用复制、冻结等实现数组的不变性,比较繁琐,计算机科学家想出了无需整体复制即可实现不可变的数据结构 ---- 共享结构体。
其他
导致代码难以理解的原因:
命名混乱的原因:
同一概念使用不同的命名
。通用代码的问题:
在命名时容易把名字绑定到具体的数据上
。函数计算得到函数
函数式编程中把函数当成值,那么能根据函数计算得到新函数吗?当然能。
通过函数计算得到新的函数,我们便可以写更小的函数,便于测试和维护。
高阶函数、柯里化、部分应用、组合和管道等,都是由函数计算得到函数的方法。
高阶函数
js 里又不少高阶函数:map、find、some、forEach、 setTimeout 等。
高级函数是函数的更高级级的函数抽象和封装,可以代码更容易理解和复用。
React 受控组件收集表单数据。
如果不使用高阶函数 每个表单项都要写一个事件处理函数,难以阅读和难以维护。
或者这样写:
显然,高阶函数更加优雅。
部分应用
函数的默认参数
ES6 支持默认参数
默认参数使得传递部分参数即可调用函数,给使用函数带来方便。
通过闭包改善以上的问题
::: tip 温习闭包
闭包:内层函数能记住外层函数作用域的特性。
闭包可访问三个作用域的变量:
闭包在外层函数执行时候创建,访问外层函数的作用域是使用闭包的目的。
部分应用、柯里化等技术,都依赖闭包。
函数式编程语言都有闭包的特性。
:::
使用闭包对默认参数的限制有所改善,还是不够灵活:返回的函数只支持一个参数。
要是能有一个函数,将上面的
rangeNumber
函数自动计算得到一个支持传递部分参数的新函数,在使用时,支持把所有参数分成任意两部分
传递,就更加灵活了,部分应用就是这种技术。:::tip 部分应用
将函数调用分成两个阶段,第一个阶段传递一部分参数,返回一个函数,第二阶段调用新的函数,传递剩余参数。
这种函数使用方式叫部分应用,可以有效拆分参数,给函数使用带来方便。
:::
改写上面的函数:
上面的部分应用函数,第一阶段传递的参数最后调用时,是在左边的,它无法处理最终调用在右边的参数。
编写一个处理右边参数的部分应用
向右边的部分应用,和函数的默认参数顺序一致,不用改写函数。
Number.parseInt(numberStr, radix)
radix 指定 numberStr 的进制,默认 10. 使用部分应用向右 vs 向左
向右的部分应用不用改写原函数,能充分利用函数的默认参数,向左的则不能,默认情况下都是向右的才是我们希望的,向左的我们另外定义函数。
柯里化
对函数多次部分应用,得到的新函数的参数越来越少。
一次性对函数的所有参数部分应用,也要再调用一次函数才能得到结果。
可以不必再调用一次函数吗?柯里化能避免再调用一次函数。
::: tip 柯里化
将多参函数变成一系列单参函数的手段。
:::
比如:
希望编写一个函数,自动把 sum 变成单参函数。
上面柯里化一个三个参数的函数,任意数量的参数如何柯里化呢?
关键代码:
实参数量小于形参数量,就递归调用
。因为在函数内部需要使用
arguments
获取实参。::: tip 箭头函数的特性
yield
操作符号;:::
Array.from(arguments)
将类类数组转为数组。::: tip 常见的转 arguments 为数组的方法
按照性能排序:
const toArray=(...params)=>params
;[].slice.call(arguments)
;Array.from(arguments)
;:::
将上面的改成箭头函数的写法:
柯里化如何改善代码
有一个日志函数:
以上调用在传递很多重复参数,可以使用柯里化改善。
::: tip 柯里化帮助去除重复参数和样板代码
:::
再来一个例子:
改进 1:
达到目的了,但是我们不得不在 filter 的回调函数中再次调用 startsWith,希望
filter(startsWith('a'))
, 显然这个更加可读,如何办?改进 2:
把 startsWith 柯里化,
参数顺序
很重要,外层函数的参数一般是固定的。部分应用 vs 柯里化
通过部分应用和柯里化,能把多参函数变成参数更小、行为更加具体的函数,方便使用。
参数更少:调用时更加方便,只需要关注变化的参数。
行为更加具体:反映到函数名称上,让函数更加自文档化。
柯里化时更加彻底的部分应用,使用部分应用,还需要再调用一次函数。
fn.length vs arguments.length
函数有一个
length
属性,表示必需
的参数数量,arguments.length
表示实际参数数量,当有默认参数和剩余参数时,两者可能不等。我们的柯里化函数用到了
fn.length
,因此无法处理默认值。为了能使用函数的默认值,可以在提供一个参数。
柯里化性能差?
::: warning 使用频繁的函数不要柯里化
由于柯里化层层嵌套,当参数很多,嵌套会很深,频繁执行的函数,柯里化后比不柯里化的函数慢。
:::
组合
Unix 或者 Linux 命令,想要计算单词
word
在给定文本中出现的次数:一个复杂的任务,通过管道组合三个简单的命令就完成了。
::: tip unix 理念
:::
理念 1:应该写职责单一的小函数,然后组合它们来处理复杂任务。
理念 2:小函数都需要输入,然后返回数据。
形如这样,函数
fnB
的输出作为fnA
输入的函数,叫函数组合。例子,给一个字符串数字进行四舍五入:
再看一个例子:统计
hello function programming
中单词个数以上例子,都是一个参数的函数组合,而且例子太简单,多个参数还能组合吗?
多个参数的组合
通过柯里化和部分应用,可以把多参函数转为一参函数,然后组合他们。
任意函数的组合
目前
compose
只能组合两个函数,想要组合两个以上函数如何写?上面统计单词的例子,还向指导单词数是奇数还是偶数。
fns.reverse()
,反转参数,希望从最后一个函数开始调用。compose(oddOrEven, count, splitSentence)
splitSentence 最开始调用。管道
希望能像 linux 命令那样使用
|
来连接多个命令,操作结果依次传递,即希望函数从左到右执行。使用上面的例子测试:
组合 vs 管道
思路一样,数据流向不同。感觉管道更符合直觉。
管道和组件满足结合律
再添加一个返回 true 或者 false 的函数
如何调试组合函数中的错误
组合和管道都是一些函数连续执行,如何知道哪个函数执行错误呢?
因为每个函数的处理的数据都不同,根根据数据输出,很容易推断出问题。
比如,想知道
splitSentence
的处理结果。参考
蒯因与引用透明
函数式编程
函数式编程的练习
What is Functional Programming? Tutorial with Example
The text was updated successfully, but these errors were encountered: