函数式编程介绍与入门

之前在看其他人交流帖子的时候提到学习函数式编程提升思维(虽然感觉有点大肆宣传的感觉),但看了一些函数式编程的例子,感觉在数据处理和多线程上有独特的效果,如果使用面向对象,那就会变得比较麻烦,所以这里仅作简单介绍.

个人认为,函式编程是一种思想,而不是具体某种工具. C++可以进行函数式编程,Java也可以,只不过看支持程度而已.现代的编程语言已经把函数式编程的思想融入进去了 .

什么是函数式编程呢?在网上你可以看到很多定义,但大都离不开这些特性。

  • First Class 函数:函数可以被应用,也可以被当作数据。
  • Pure 纯函数,无副作用:任意时刻以相同参数调用函数任意次数得到的结果都一样。
  • Referential Transparency 引用透明:可以被表达式替代。
  • Expression 基于表达式:表达式可以被计算,促进数据流动,状态声明就像是一个暂停,好像数据到这里就会停滞了一下。
  • Immutable 不可变性:参数不可被修改、变量不可被修改—宁可牺牲性能,也要产生新的数据(Rust内存模型例外)。
  • High Order Function 大量使用高阶函数:变量存储、闭包应用、函数高度可组合。
  • Curry 柯里化:对函数进行降维,方便进行组合。
  • Composition 函数组合:将多个单函数进行组合,像流水线一样工作。

另外还有一些特性,有的会提到,有的一笔带过,但实际也是一个特性(以Haskell为例)。

  • Type Inference 类型推导:如果无法确定数据的类型,那函数怎么去组合?(常见,但非必需)
  • Lazy Evaluation 惰性求值:函数天然就是一个执行环境,惰性求值是很自然的选择。
  • Side Effect IO:一种类型,用于处理副作用。一个不能执行打印文字、修改文件等操作的程序,是没有意义的,总要有位置处理副作用。(边缘)

函数式编程语言的一些核心特点,如不可变数据、高阶函数、惰性求值等。它们为现代软件开发带来了新的编程模式和思维方式。
本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法,只是碰巧它能用来写程序。

  • 数据是不可变(immutable)的: 如果要更改数据(如数组),需要返回一个包含更改内容的新数组,而不是原来的数组。

  • 函数是无状态的: 函数每次都像第一次一样运行!换句话说,对于相同的参数,函数总是给出相同的返回值。

一般来说,您应该遵循三个最佳实践:

  1. 函数应至少接受一个参数。
  2. 函数应返回数据或另一个函数。

  3. 不要使用循环

函数一等公民

  • 函数是”头等公民”,可以作为参数传递,返回值,赋值给变量等。
  • 允许编写高阶函数,增强了代码的灵活性和表达力。

函数没什么特殊的,你可以像对待任何其他数据类型一样对待它们——把它们存在数组里,当作参数传递,赋值给变量…等等。

具体来说,看看别人的例子

1
2
3
4
5
const hi = name => `Hi ${name}`;
const greeting = name => hi(name); // 没有必要的操作

const greeting = hi; // 相同的作用
greeting("times"); // "Hi times"

再来一个例子

1
2
3
4
5
6
7
8
9
10
11
// 这行
ajaxCall(json => callback(json));

// 等价于这行
ajaxCall(callback);

// 那么,重构下 getServerStuff
const getServerStuff = callback => ajaxCall(callback);

// ...就等于
const getServerStuff = ajaxCall // <-- 看,没有括号哦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const BlogController = {
index(posts) { return Views.index(posts); },
show(post) { return Views.show(post); },
create(attrs) { return Db.create(attrs); },
update(post, attrs) { return Db.update(post, attrs); },
destroy(post) { return Db.destroy(post); },
};

const BlogController = {
index: Views.index,
show: Views.show,
create: Db.create,
update: Db.update,
destroy: Db.destroy,
};

这样修改有什么意义?=>除了节省代码,减少冗余之外,外面的函数修改后,里面的函数可以不用改了.这也增加了可重用性,说到可重用性,还有一点就是在不涉及到具体业务的函数上命名应该更加通用.

另外涉及到类似js中的this值时最好使用bind,apply等绑定避免出错.

纯函数

纯函数(Pure Function)是函数式编程中的一个核心概念。它有以下几个特点:

  1. 无副作用(No Side Effects):
    • 纯函数不会修改函数外部的任何状态,如全局变量、I/O操作等。
    • 只依赖于输入参数,不会产生意外的输出。
  2. 确定性(Deterministic):
    • 对于相同的输入,纯函数总是返回相同的输出。
    • 没有”隐藏的”依赖或状态会影响函数的结果。
  3. 可测试性(Testable):
    • 纯函数易于单元测试,因为不需要考虑外部环境的影响。
    • 可以独立地测试每个函数,不会受到其他函数的干扰。
  4. 可组合性(Composable):
    • 纯函数可以被轻松地组合成更复杂的功能。
    • 函数的输出可以作为另一个函数的输入,形成管道式的数据处理。
  5. 并发性(Concurrency):
    • 由于没有副作用,纯函数可以安全地在多线程环境中并发执行。
    • 不需要担心竞争条件和同步问题。

通常我们定义输入输出(IO)是不纯的,因为IO操作不仅操作了数据,还操作了这个数据范畴外部的世界,比如打印、播放声音、修改变量状态、网络请求等。这些操作并不是说对程序造成了破坏,相反,一个完整的程序一定是需要它们的,不然我们的所有计算都将毫无意义

但纯函数是可预测的,引用透明的,我们希望代码中更多地出现纯函数式的代码,这样的代码可以被预测,可以被表达式替换,而更多地把IO操作放到一个统一的位置做处理

副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互

此外会对输入数据原地改变的函数也是不纯的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var xs = [1,2,3,4,5];

// 纯的
xs.slice(0,3);
//=> [1,2,3]

xs.slice(0,3);
//=> [1,2,3]

xs.slice(0,3);
//=> [1,2,3]


// 不纯的
xs.splice(0,3);
//=> [1,2,3]

xs.splice(0,3);
//=> [4,5]

xs.splice(0,3);
//=> []

追求“纯”的理由

from 第 3 章: 纯函数的好处 · 函数式编程指北 (gitbooks.io)

可缓存性(Cacheable)

首先,纯函数总能够根据输入来做缓存。实现缓存的一种典型方式是 memoize 技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
var squareNumber  = memoize(function(x){ return x*x; });

squareNumber(4);
//=> 16

squareNumber(4); // 从缓存中读取输入值为 4 的结果
//=> 16

squareNumber(5);
//=> 25

squareNumber(5); // 从缓存中读取输入值为 5 的结果
//=> 25

下面的代码是一个简单的实现,尽管它不太健壮。

1
2
3
4
5
6
7
8
9
var memoize = function(f) {
var cache = {};

return function() {
var arg_str = JSON.stringify(arguments);
cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
return cache[arg_str];
};
};

值得注意的一点是,可以通过延迟执行的方式把不纯的函数转换为纯函数:

1
2
3
var pureHttpCall = memoize(function(url, params){
return function() { return $.getJSON(url, params); }
});

这里有趣的地方在于我们并没有真正发送 http 请求——只是返回了一个函数,当调用它的时候才会发请求。这个函数之所以有资格成为纯函数,是因为它总是会根据相同的输入返回相同的输出:给定了 urlparams 之后,它就只会返回同一个发送 http 请求的函数。

我们的 memoize 函数工作起来没有任何问题,虽然它缓存的并不是 http 请求所返回的结果,而是生成的函数。

现在来看这种方式意义不大,不过很快我们就会学习一些技巧来发掘它的用处。重点是可以缓存任意一个函数,不管它们看起来多么具有破坏性

可移植性/自文档化(Portable / Self-Documenting)

纯函数是完全自给自足的,它需要的所有东西都能轻易获得。仔细思考思考这一点…这种自给自足的好处是什么呢?首先,纯函数的依赖很明确,因此更易于观察和理解——没有偷偷摸摸的小动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 不纯的
var signUp = function(attrs) {
var user = saveUser(attrs);
welcomeUser(user);
};

var saveUser = function(attrs) {
var user = Db.save(attrs);
...
};

var welcomeUser = function(user) {
Email(user, ...);
...
};

// 纯的
var signUp = function(Db, Email, attrs) {
return function() {
var user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
};

var saveUser = function(Db, attrs) {
...
};

var welcomeUser = function(Email, user) {
...
};

这个例子表明,纯函数对于其依赖必须要诚实,这样我们就能知道它的目的。仅从纯函数版本的 signUp 的签名就可以看出,它将要用到 DbEmailattrs,这在最小程度上给了我们足够多的信息。

后面我们会学习如何不通过这种仅仅是延迟执行的方式来让一个函数变纯,不过这里的重点应该很清楚,那就是相比不纯的函数,纯函数能够提供多得多的信息

其次,通过强迫“注入”依赖,或者把它们当作参数传递,我们的应用也更加灵活;因为数据库或者邮件客户端等等都参数化了(别担心,我们有办法让这种方式不那么单调乏味)。如果要使用另一个 Db,只需把它传给函数就行了。如果想在一个新应用中使用这个可靠的函数,尽管把新的 DbEmail 传递过去就好了,非常简单。

在 JavaScript 的设定中,可移植性可以意味着把函数序列化(serializing)并通过 socket 发送。也可以意味着代码能够在 web workers 中运行。总之,可移植性是一个非常强大的特性。

命令式编程中“典型”的方法和过程都深深地根植于它们所在的环境中,通过状态、依赖和有效作用(available effects)达成;纯函数与此相反,它与环境无关,只要我们愿意,可以在任何地方运行它。

你上一次把某个类方法拷贝到新的应用中是什么时候?我最喜欢的名言之一是 Erlang 语言的作者 Joe Armstrong 说的这句话:“面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩…以及整个丛林”。

可测试性(Testable)

第三点,纯函数让测试更加容易。我们不需要伪造一个“真实的”支付网关,或者每一次测试之前都要配置、之后都要断言状态(assert the state)。只需简单地给函数一个输入,然后断言输出就好了。

事实上,我们发现函数式编程的社区正在开创一些新的测试工具,能够帮助我们自动生成输入并断言输出。这超出了本书范围,但是我强烈推荐你去试试 Quickcheck——一个为函数式环境量身定制的测试工具。

合理性(Reasonable)

很多人相信使用纯函数最大的好处是引用透明性(referential transparency)。如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。

由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。我们来看一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var Immutable = require('immutable');

var decrementHP = function(player) {
return player.set("hp", player.hp-1);
};

var isSameTeam = function(player1, player2) {
return player1.team === player2.team;
};

var punch = function(player, target) {
if(isSameTeam(player, target)) {
return target;
} else {
return decrementHP(target);
}
};

var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});
var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});

punch(jobe, michael);
//=> Immutable.Map({name:"Michael", hp:19, team: "green"})

decrementHPisSameTeampunch 都是纯函数,所以是引用透明的。我们可以使用一种叫做“等式推导”(equational reasoning)的技术来分析代码。所谓“等式推导”就是“一对一”替换,有点像在不考虑程序性执行的怪异行为(quirks of programmatic evaluation)的情况下,手动执行相关代码。我们借助引用透明性来剖析一下这段代码。

首先内联 isSameTeam 函数:

1
2
3
4
5
6
7
var punch = function(player, target) {
if(player.team === target.team) {
return target;
} else {
return decrementHP(target);
}
};

因为是不可变数据,我们可以直接把 team 替换为实际值:

1
2
3
4
5
6
7
var punch = function(player, target) {
if("red" === "green") {
return target;
} else {
return decrementHP(target);
}
};

if 语句执行结果为 false,所以可以把整个 if 语句都删掉:

1
2
3
var punch = function(player, target) {
return decrementHP(target);
};

如果再内联 decrementHP,我们会发现这种情况下,punch 变成了一个让 hp 的值减 1 的调用:

1
2
3
var punch = function(player, target) {
return target.set("hp", target.hp-1);
};

总之,等式推导带来的分析代码的能力对重构和理解代码非常重要。事实上,我们重构海鸥程序使用的正是这项技术:利用加和乘的特性。对这些技术的使用将会贯穿本书,真的。

并行代码

因为可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。

并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

组合

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做”函数的合成”(compose)

将计算过程分解成可复用的函数,典型例子就是map方法和reduce方法组合而成MapReduce算法

1
2
3
4
5
compose(f, compose(g, h))
// 等同于
compose(compose(f, g), h)
// 等同于
compose(f, g, h)

柯里化

在学习JS的时候,你可能已经注意到这个概念了

f(x)g(x)合成为f(g(x)),有一个隐藏的前提,就是fg都只能接受一个参数。如果可以接受多个参数,比如f(x, y)g(a, b, c),函数合成就非常麻烦。

这时就需要函数柯里化了。所谓”柯里化”,就是把一个多参数的函数,转化为单参数函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 柯里化之前
function add(x, y) {
return x + y;
}

add(1, 2) // 3

// 柯里化之后
function addX(y) {
return function (x) {
return x + y;
};
}

addX(2)(1) // 3

有了柯里化以后,我们就能做到,所有函数只接受一个参数。后文的内容除非另有说明,都默认函数只有一个参数,就是所要处理的那个值。

除了上面提到的之外,还有很多其他特性,就需要自己学习了.

Functor

Functor可以简单地理解为一个能够保存值,并且能够对这些值执行某些操作的容器。更具体地说,Functor是一个实现了fmap函数的类型类。fmap函数允许我们对Functor中包含的值执行某种变换操作,并将结果包装到新的Functor中返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 定义一个列表
numbers = [1, 2, 3, 4, 5]

# 使用fmap对列表中的元素进行平方变换
squared_numbers = list(map(lambda x: x**2, numbers))
print(squared_numbers) # Output: [1, 4, 9, 16, 25]

from typing import Optional
# Option类型作为Functor
def safe_divide(a: int, b: int) -> Optional[int]:
if b == 0:
return None
else:
return a / b

# 使用fmap对Optional类型执行变换
result1 = safe_divide(10, 2)
print(result1) # Output: 5.0

result2 = safe_divide(10, 0)
print(result2) # Output: None

# 使用fmap对Maybe类型执行变换
doubled_result1 = lambda x: x * 2
print(list(map(doubled_result1, [result1, result2]))) # Output: [10.0, None]

Monoid

Monoid是一个具有以下两个特性的代数结构:

  1. 二元操作(称为”合并”操作)
  2. 一个特殊的单位元

更具体地说,一个类型T是一个Monoid,如果它满足以下两个条件:

  1. 存在一个二元操作combine(a: T, b: T) -> T,该操作是associative的,即combine(a, combine(b, c)) = combine(combine(a, b), c)
  2. 存在一个特殊的值empty: T,被称为单位元,对于任意x: T都有combine(x, empty) = xcombine(empty, x) = x
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 整数求和操作
numbers1 = [1, 2, 3]
numbers2 = [4, 5, 6]
total = sum(numbers1) + sum(numbers2)
print(total) # Output: 21

# 0作为单位元
print(sum([]) + sum(numbers1)) # Output: 6
print(sum(numbers1) + sum([])) # Output: 6

# 字符串拼接操作
greeting1 = "Hello, "
greeting2 = "world!"
combined_greeting = greeting1 + greeting2
print(combined_greeting) # Output: "Hello, world!"

# 空字符串作为单位元
empty_string = ""
print(empty_string + greeting1) # Output: "Hello, "
print(greeting1 + empty_string) # Output: "Hello, "

Monad

可以被视为Functor的一种特殊形式,具有额外的性质和操作。

简单来说,Monad是一个满足以下条件的类型M[_]:

  1. 它是一个Functor,即存在fmap操作,可以对Monad内部的值进行变换。
  2. 它具有一个return操作,可以将一个值包装进Monad。
  3. 它具有一个flatMap(或bind)操作,可以对Monad内部的值进行变换并”扁平化”结果。
1
2
3
4
5
6
7
8
9
10
11
12
def multiply(x: int) -> list[int]:
return [x, x * 2, x * 3]

# 使用flatMap操作
numbers = [1, 2, 3]
results = [y for x in numbers for y in multiply(x)]
print(results) # Output: [1, 2, 3, 2, 4, 6, 3, 6, 9]

# 使用列表推导式实现flatMap
results = [multiply(x) for x in numbers]
print(results) # Output: [[1, 2, 3], [2, 4, 6], [3, 6, 9]]

图解 Monad - 阮一峰的网络日志 (ruanyifeng.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// IO类型
class IO {
constructor(effect) {
this._effect = effect;
}

// pure操作
static of(value) {
return new IO(() => value);
}

// bind操作
bind(fn) {
return new IO(() => fn(this._effect()).run());
}

run() {
return this._effect();
}
}

// 示例
const greet = name => `Hello, ${name}!`;
const getInput = () => prompt('What is your name?');

const sayHello = new IO(getInput)
.bind(name => new IO(() => greet(name)))
.bind(message => new IO(() => alert(message)));

sayHello.run(); // 弹出对话框并显示'Hello, {输入的名字}!'

Applicative

Applicative可以被视为Functor的一种特殊形式,具有以下特点:

  1. 它是一个Functor,即存在fmap操作,可以对Applicative内部的值进行变换。
  2. 它具有一个pure操作,可以将一个值包装进Applicative。
  3. 它具有一个ap(apply)操作,可以将一个函数包装进Applicative应用到另一个Applicative上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Either类型
class Either {
constructor(value) {
this._value = value;
}

static of(value) {
return new Right(value);
}

static left(value) {
return new Left(value);
}

// ap操作
ap(other) {
if (this instanceof Left) {
return this;
}
if (other instanceof Left) {
return other;
}
return new Right(this._value(other._value));
}
}

class Right extends Either {
// pure操作
static of(value) {
return new Right(value);
}
}

class Left extends Either {
// pure操作
static of(value) {
return new Left(value);
}
}

// 示例
const add = (x, y) => x + y;
const result1 = Either.of(add).ap(Either.of(3)).ap(Either.of(4)); // Right(7)
const result2 = Either.of(add).ap(Either.of(3)).ap(Either.left('error')); // Left('error')

图解 Functor、Applicative、Monad (sxyz.blog)

图解 Functor, Applicative 和 Monad | Forec’s Notes

图 30

使用函数式编程的一些原因

  • 方便debug

因为写成了函数,方便写单元测试而且由于是纯函数,输入固定那输出就是固定的

  • 代码重用

纯函数方便组合也方便使用

  • 另一种思维方式

是的,除了OOP之外,你可以站在这样的角度写特别的代码

如果你要学习具体某个函数式编程语言,可以考虑Clojure,Elixir,Haskell或者更加新的Ocaml.(我个人推荐)

下面给一些函数式编程语言分个类.

Lisp 家族

  • Common Lisp
  • Scheme
  • Racket
  • Clojure

这些语言都属于 Lisp 语言家族,具有强大的宏系统和元编程能力,非常适合进行函数式和声明式编程。

支持函数式编程的多范式语言

  • Scala
  • F#
  • Clojure
  • Kotlin
  • Swift

这类语言融合了函数式和面向对象等多种编程范式,兼具函数式和命令式编程的特点。它们在工业界应用较为广泛。

纯函数式语言

  • Haskell
  • PureScript

这类语言严格遵循函数式编程的原则,没有可变状态,强调代数类型系统和惰性求值。它们更偏向于学术和研究领域。

并发/分布式函数式语言:

  • Erlang
  • Elixir
  • OCaml

这些语言擅长构建高并发、分布式和容错的应用程序,利用函数式编程的优势来应对复杂的并发问题。

JavaScript 衍生语言

  • PureScript
  • Elm
  • ReasonML
  • TypeScript

这类语言在 JavaScript 的基础上增加了静态类型系统和其他函数式特性,旨在提高 JavaScript 的可靠性和可维护性

参考资料

  1. Functional Programming 101 (github.com)
  2. Introduction · 函数式编程指北 (gitbooks.io)
  3. 函数式编程入门教程 - 阮一峰的网络日志 (ruanyifeng.com)
  4. 深入理解函数式编程(上) - 美团技术团队 (meituan.com)
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道