Author:Wzy_CC
全文一共4000字
阅读时间10分钟
前言
本文目标:
闭包详解、函数式入门、Golang的普通函数柯里化
写作目的:
彻底解决非纯函数式编程的思维障碍
阅读顺序:
本文按照顺序阅读会比较有帮助
目录
[TOC]
闭包
引言
来看一段python3代码:
>>>for i in range(3):
... a = i
>>>print(a)
2
>>>print(i)
2
怎么在for-loop
外访问到了a变量和i变量?原因是在python中并不是所有的语句块都会产生作用域,比如for循环就不引入局部作用域。
(LEGB作用域法则:当在当前作用域内无法寻找到变量时,逐层向上查找,搜索不到则报错)
再来看一段js代码:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0](); // ?
data[1](); // ?
data[2](); // ?
上面的输出全部是3,原因在于真正执行的时候,i的值已经被赋值为3了。这就产生一个很大的问题,既然作用范围产生了泄漏,那么在下次使用变量i的时候,或者在后面的代码块引用到a变量的时候,岂不会出现问题?
所以引入了闭包。很多语言都支持函数作为头等公民,比如JavaScript,Ruby,Python,C#,Scala,Java8.....,而这些语言里无一例外的都提供了闭包的特性,因为闭包可以大大的增强函数的处理能力,函数可以作为一类对象的这一优点才能更好的发挥出来。
函数
首先一个非常重要的前提是:函数必须是头等公民,可以把函数赋值给变量,也可以把函数作为其它函数的参数或者返回值。
闭包定义
延长作用域链的就叫做闭包
理解闭包
一句话解释:
1.闭包是词法作用域的体现
2.一个持有外部环境变量的函数就是闭包
3.不过最好还是看一下轮子哥的解释:
闭包不是私有啊,闭的意思不是“封闭内部状态”,而是封闭外部状态啊。一个函数如何能封闭外部状态呢,当外部状态的scope失效的时候,还有一份留在内部状态里面
你可以理解成在闭包创建时,把引用的变量的值拷贝了一份。以此实现内外环境的隔离。参考C++的lambda表达式的实现就知道了。
从本质上看,闭包就是上述解释,但是从结果来定义闭包更有助于理解:
闭包就是把数据和作用域绑定到一起的一种方法
(”纯“函数式开发者认为:一个”纯“函数是不应该存在状态的,而”不纯“的函数是带有副作用的,是不好的)
理解闭包通常有着以下几个关键点:
1.函数
2.自由变量
3.环境
例子
Javascript语言比较特殊的一点,就是可以在函数内部直接读取全局变量:
let a = 1
let b = function(){
console.log(a)
}
在函数b的内部直接读取到了a的值,而这在C/Go中简直就是不可想象的。
在这个例子里函数b因为捕获了外部作用域(环境)中的变量a,因此形成了闭包。 而由于变量a并不属于函数b,所以在概念里被称之为”自由变量“,再举个例子:
function a(x, y){
console.log(x, y) // 在这里,x和y都不是自由变量
function b(){
console.log(x, y) // 但在这个内部函数b中,x和y相对于b都是自由变量,而函数a的作用域则是环境。
}
// 无论b最终是否会作为返回值被函数a返回,b本身都已经形成了闭包。
}
当然在函数外也可以访问到函数内的变量(原因就在于默认声明为全局变量)
function b(){
a = 999; // 全局变量
}
alert(a); // 999
使用var
关键字可以声明为局部变量:
function b(){
var a = 999; // 局部变量
}
alert(n); // error
从外部读取函数体内变量有两个方法:声明全局变量或者闭包。而导出声明全局变量的代码不仅不易维护,还会因为全局变量泛滥的问题导致覆盖和引用错误。
使用闭包可以有效的解决这个问题:
function f1(){
var n = 999;
function f2(){
alert(n); // 999
}
return f2;
}
var result = f1();
result(); // 999
和python相似,javascript也有独特的域结构,称作“链式作用域”,父对象的所有变量对于子对象都是可见的,反之不行。
因此在上面的代码中,使用f1函数返回值可以访问到处于f1内部的变量n
欸,那下面这行代码不是可以产生同样的效果么,直接返回函数内的值:
function f1(){
var n = 999;
return n;
}
var result = f1();
alert(result);
区别是很大的,使用后种方法调用时每次都会声明n = 999,在函数执行完成后无法保留下n的值,换句话说,无法保存此刻n的状态。而使用闭包后返回的匿名函数的好处是,n的作用域链被“延长”至f2的函数体内,被返回到外面,n 不会被重复声明,且内容会被保留。
比如说,函数体内在不断修改n的值(类似于C++中类的static成员,static成员分配在内存的堆上,和其他实例一起共享这个值),那么这个时候很显然闭包的作用就真正发挥出来了,让这些变量的值永驻内存(python中装饰器是闭包的一种实现):
function f1(){
var n = 999;
nAdd = function(){n += 1} // 第3行
function f2(){
alert(n);
}
return f2;
}
var result = f1();
result(); // 999
nAdd();
result(); // 1000
f1中的局部变量n是一致保存在内存中的,并没有在f1调用后就被回收,因为f1是f2的父函数,而f2被赋给了一个全局变量,这导致f2始终在内存中,而f2的存在依赖于f1,因此f1也始终在内存中。
事实上,第三行代码也是一个闭包,参考闭包的定义。nAdd破坏了函数的封装性,使之在外部访问到了内部变量。
事实上,上面的代码稍加改动一下就可以实现python中的迭代器和生成器了,非常简单:
function f1(){
var n = 0;
function f2(){
alert(n);
n += 1;
}
return f2;
}
var result = f1();
result(); // 0
result(); // 1
如果在迭代器前没有学习过闭包,那么理解起来确实会比较吃力。
下面这个例子可以更好的说明,这种对于变量状态的保存有什么用:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){ // 暂时取名 fn
console.log(i);
} // i是自由变量,所以这是一个闭包
})(i);
}
data[0](); // output 0
data[1](); // output 1
data[2](); // output 2
fn函数提供给我们访问到i的值的功能,而这个变量i屏蔽了全局变量i,熟悉js的开发者应该知道,全局i此时值为3,而输出却是012,闭包起到了保存变量状态的功能。
从这点上看,一个持有外部环境变量的函数就是闭包
无论函数在哪里被调用或者如何被调用,它的词法作用域都只由函数被声明时所处的位置决定,所以,函数所能访问变量的"权限"只由声明位置决定,利用这个特性就可以利用闭包(将这个函数return出去,该函数声明位置不变,所以可访问变量的"权限"依然不变)使函数外部可以访问函数内部的变量。
参考轮子哥的解释,闭包的”闭“是封闭,是在外部scope”失效“时或者”失效“后封闭冻结了外部状态,所以说是延长了作用域链。python或者js开发者会经常使用到闭包,原因就是局部变量还往往需要破坏函数封装。如果不使用闭包,那么就会失去代码本身的整洁优雅,功能也往往不合理。
JavaScript 闭包的本质源自两点,词法作用域和函数当作值传递。
词法作用域,就是按照代码书写时的样子,内部函数可以访问函数外面的变量。引擎通过数据结构和算法表示一个函数,使得在代码解释执行时按照词法作用域的规则,可以访问外围的变量,这些变量就登记在相应的数据结构中。
函数当作值传递,即所谓的first class对象。就是可以把函数当作一个值来赋值,当作参数传给别的函数,也可以把函数当作一个值 return。一个函数被当作值返回时,也就相当于返回了一个通道,这个通道可以访问这个函数词法作用域中的变量,即函数所需要的数据结构保存了下来,数据结构中的值在外层函数执行时创建,外层函数执行完毕时理因销毁,但由于内部函数作为值返回出去,这些值得以保存下来。而且无法直接访问,必须通过返回的函数。这也就是私有性。本来执行过程和词法作用域是封闭的,这种返回的函数就好比是一个虫洞开了挂。
总结
闭包解决了什么问题:变量作用的生命周期问题、链式调用问题
如何解决这个问题的:状态保存、柯里化
闭包有什么好处:作用域外延
为什么闭包能解决这个问题:函数是第一公民
柯里化/反柯里化
引言
高深的概念往往都从简单的例子开始:
在开发过程中,假设我们需要一个func add()
函数,用于相加两个int
型变量的和:
func add(a ,b int) int {
return a + b
}
非常简单的例子,实现了返回两个整数的和的功能
再进一步思考,假设我们需要相加三个数、四个数甚至多个数怎么办?
Go也提供了相应的语法糖来实现不定参数的传递:
func add(a ...int) (sum int) {
for _, v := range a {
sum += v
}
return
}
当然a可以遍历,说明了不定参数的本质还是切片,上述函数返回了所有整数的和
这个功能差不多已经可以应用于生产环境了,但是等等...
如果我想要链式调用呢?如果我在计算完1 + 2 后又想计算( 1+2 ) + 3呢:
add(1)(2)(3)
那么必须要求add()
函数本身返回的也是一个可供调用的函数
欸,计算1+2+3直接使用add(1,2,3)
不就行了么?为什么需要返回一个函数呢?
假设场景:设计add()
函数计算分数和,当然为了复用不仅仅只能计算分数的和,还应当能计算一个年级的学生数量总和、一个班级的分数总和等等功能。
抽象成OO编程的话,实际上我们需要两个步骤:1.提取对象要加和的属性值;2.将提取出来的属性值相加
那么对于不同对象的不同属性值,也必然存在不同方法来访问获取这个值,比如获取学生类中的身高函数是getStudentHeight()
,获取一个班级的学生人数的方法getStudentNum()
所以如果想要做一个通用化的加和函数,add()
的参数是一定需要传递一个函数的,举个例子,我们需要计算一个班级的身高之和:
// Code by Golang
func add(studentArray []student,getStudentHeight func(s student) int) int {}
分别传入学生类的数组和获取身高属性的函数,最后返回身高和
但是在实际生产中,我觉得设置那么多的get函数实在是太麻烦了,我也不可能实际上做这么多重复性的工作,所以我参考了工厂模式,写了一个工厂函数:
func getPropertyFactory(productName string) func (o interface{}) int {}
这个工厂函数负责帮我生成各式各样的get函数,只需要传入属性名称,比如我想要生成getStudentHeight()
:
fn := getPropertyFactory("Height")
可以看到fn本质上就是一个getHeight方法,我们如果想要获取学生身高可以:
WzyCCHeight := fn(studentWzyCC)
既然可以如上调用,那么也一定可以:
getPropertyFactory("Height")(studentWzyCC)
好了,有点柯里味儿了...
当然这也是王垠所说的,为什么在函数式编程看来这么简单的概念,在OOP中却需要又抽象又难懂的各种设计模式和概念术语 轮子哥名言:从纯粹的语义上,curry化就是lambda表达式的一个糖,不要想太多。
柯里化定义
柯里化来自Haskell Curry,也是Haskell语言名字的由来
把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
高阶函数
事实上,在Haskell中Currying指的是这样的一个高阶函数:
curry :: ((a, b) -> c) -> (a -> b -> c)
它把函数f
变成函数g
,
f :: ((a, b) -> c)
g :: a -> b -> c
g = curry f
f = uncurry g
使得任意的x,y,满足,
f (x, y) = g x y
将普通函数柯里化
让我们使用Go将最基础的函数add()
柯里化吧:
// 定义可柯里化函数形式
type function func(...interface{}) interface{}
// 通用柯里化函数
func (f function) curry(i interface{}) func(...interface{}) interface{} {
return func(values ...interface{}) interface{} {
values = append([]interface{}{i}, values...)
fmt.Println(values)
return f(values...)
}
}
// 等待实现柯里化的普通函数
func add(a, b int) int {
return a + b
}
func TestCurry(t *testing.T) {
// 把普通的函数转化成可柯里化函数
var addCurry function = func(values ...interface{}) interface{} {
return add(values[0].(int), values[1].(int))
}
// 调用柯里化过程生成新函数
add5 := addCurry.curry(5)
// 调用新函数产生最终结果
v := add5(8)
if v != 13 {
t.Error("期望13,实际", v)
}
}
非常不优雅,下一篇文章决定好好研究一下如何将一个普通函数柯里化
总结
柯里化解决了什么问题:普通函数的链式调用问题
如何解决这个问题的:略
柯里化有什么好处:OOP中对理解设计模式起到了一定的帮助
为什么柯里化能解决这个问题:函数是第一公民