数学科普作家张远南先生有一本数学科普书叫做《变量中的常量:函数的故事》,事实上点出了函数分析中的关键,即研究变化中的不变量。对于函数式编程来说,其核心思想也是抓住变化的程序中的不变结构。
对比一下命令式编程思路和函数式编程思路:
- 命令式编程:程序员像工匠一样控制过程(Procedure)中对变量的加工过程(保存、载入、计算等等)。
- 函数式编程:程序员像数学家、逻辑学家,编写程序如同书写具有高度逻辑性的论文,通过声明一系列命题(Proposition)来构造整个程序结构。
事实上,函数式编程的核心在于抓住变化中的不变 - 命令式编程中,数据和行为都是可变的,也就是说即使相同的数据输入,由于某些外部状态的变化(如用户操作等),函数可能返回不同的值,过程的操作也可能变得无法预测。
- 函数式编程中,数据是可变的,但行为是不变的。对于纯函数而言,返回值完全由输入参数决定,不存在其他的可能。
什么是行为:过程(Procedure)和函数(Function)都是行为,也就是对待输入参数的计算机响应。
纯函数式编程本质上是基于常量的编程,程序中的所有符号本质都是常量,只不过这里的常量是相对于其作用域和声明周期而言的。
注意:这里纯函数式编程需要区别于函数式编程,特别是一些符号式编程语言中的函数式风格,如Scheme、LISP语言,它们仍然提供过程式编程方式,允许用户修改某些变量的数值。
那么,一个问题来了:函数式编程是如何实现复杂的行为呢?
- 对于命令式、过程式编程,程序员通过复杂的命令控制来实现复杂的行为,这是毋庸置疑的。
- 对于纯函数式语言来说,程序员不可以通过直接改变符号的值来实现复杂的行为逻辑。
- 函数式语言通过高阶函数。特别是那些返回函数的函数来实现复杂的行为,在这种情况下,高阶函数的某些参数可以选择返回的函数从而使得最终运行的程序具有复杂的行为。例如:
add x y = x + y
sub x y = x - y
select "add" = add
select "sub" = sub
select _ = error "invalid_function"
上述Haskell程序通过简单的名字选择了目标函数,将名称"add"
和函数add
关联起来,将名称"sub"
和函数sub
关联起来。
同样的程序如果用C++/C语言编写,并且不使用函数式方式编程,那么效果就会是这样:
int select(const std::string& name, int p1, int p2)
{
if(name=="add")
{
return p1+p2;
}else
{
return p1-p2;
}else
{
throw std::exception("invalid_function");
}
}
单纯这样还看不出显著的差别,甚至有些人觉得过程式的方式更加方便。但是我们仔细分析一下,从程序的目的看,select
想表达的意思是根据名称返回一个函数,并不是根据名称立即对参数p1
和p2
进行运算。如果程序员想在后续再将选择到的函数进行运算的话,写起来就较为复杂。C++中使用函数式参考文章。
我们也可以使用面向对象的方式来实现类似的行为:
class funcbase
{
public:
virtual ~funcbase() {};
virtual int operator() (int,int) = 0;
};
class add : public funcbase
{
public:
virtual int operator() (int p1, int p2) { return p1 + p2; }
};
class sub : public funcbase
{
public:
virtual int operator() (int p1, int p2) { return p1 - p2; }
};
funcbase* select(const std::string& name)
{
if(name=="add") return new add();
if(name=="sub") return new sub();
return nullptr;
}
显然这程序变得较为复杂。
- 现代C++(指C++11以上)对函数式编程支持较好。但是这里暂时不作讨论。
事实上,函数式编程的内涵远远不止如此,函数式编程最重要的是寻找不变的程序结构。哪些程序结构是不变的呢?最常见的是递归,一个简单的求和递归函数:
sum' [] = 0
sum' (x:xs) = x + sum' xs
这段程序非常简单,程序结构中的不变量其实是
x+sum xs
这个形式。任何能接受+
运算的类型(即满足Num a
的类型约束条件)的列表类型[a]
都能够进行求和。
这里取名sum'
是为了和Haskell内置的sum
函数作区分。
当然更为复杂的程序结构可以由的接口这个概念来呈现。在很多面向对象或者过程式语言都提到接口的概念,这其实是对行为的一种固定,例如C++语言的基类虚函数就是接口。对于面向对象语言而言,不同的类型只要有相同的接口,那么程序其他地方如果只关心接口本身的逻辑而不关心对象的其他内涵,那么就可以直接使用接口方式调用子程序,例如C++用基类指针或引用调用子类的虚函数。同时,使用接口设计有时候平衡子程序复用和子程序特殊化,这主要是通过默认接口和函数重载来实现的。
Haskell语言提供了typeclass,实际上可以提供类似接口的功能,甚至比接口更为强大。例如,如果用户自定义类型需要使用算术算符+
、-
和*
,那么只需要实现typeclass Num
的实例。例如:
data Vector = Vector a a a
scatter op = \(Vector x1 y1 z1) (Vector x2 y2 z2) -> Vector (op x1 x2) (op y1 y2) (op z1 z2)
instance Num a => Num (Vector a) where
(+) = scatter (+)
(-) = scatter (-)
(*) = scatter (*)
-- ...
- 这里省略了其他
Num
的接口定义,如果要完整的支持,程序员需要自行实现其余接口。- 上述程序里,使用一个高阶函数
scatter
即可将针对普通a
类型的接口提升为Vector a
类型的接口。- 这样的程序用C语言回非常复杂,需要使用宏来简化编程;而对于C++来说可能需要模板元编程来实现。
-
函子Functor是Haskell重要的程序结构
我们看一下Functor的声明:
class Functor f where
fmap :: (a -> b) -> f a -> f b
Haskell的列表类型[]
就是一个Functor。其fmap
实现也非常好理解,就是将第一个参数(是个一元函数)直接作用在列表的每个元素上,并且将结果重新组织成列表,且结果的次序结构与原列表。
这里要注意一点,绝对不变能把
f a
看成数据,而是要把它看成关于a
类型的一段程序构造。
下面给出函数的函子定义:
data Function a b = Function (a->b)
instance Functor (Function a) where
fmap f (Function g) = Function (f . g) -- f :: b -> b1
上面这个例子中
data Function
本质上只是一元函数的一个封装,而其函子行为就是其函数复合行为,并且是左结合,将其返回值类型从b
替换为b1
类型。
一些更复杂的结构也是函子
更复杂的typeclass还包括Applicative、Monad等。
- 函数式编程中将一个函数变换为另一个函数的函数很重要,例如Functor的
fmap
,又比如Applicative的liftA2
这样的高阶函数有点像数学中的算子。
我们可以联想到数学中线性空间和线性映射、向量和矩阵、函数与微分方程。
我们可以回忆一下:线性映射是保持线性空间的线性结构(线性组合)不变的态射(morphism),是一种同态态射(homomorphism),如果函子f a
中的a
是线性空间,那么f a
可以是线性映射吗?如果f a
是线性映射,那么其函子行为应当是如何的?
如果我们将线性映射组成一个有向图,实际上我们可以很容已得到一个Tensor Flow的结构。Tensor Flow本质上就是一个参数化的张量网络,可以将输入端的向量传输为输出端的向量(例如分类结果),通过学习(在给定一套度量学习效果的计算方法和修正参数的策略)最终得到一个满足优化目标的传输网络,这就是机器学习的本质。(注意结合最优传输理论Optimal Transport理解)
- 函数式编程中的另一个重要的不变结构是Monad
Monad是一种函子,且一定是应用型函子(Applicative Functor)
Monad的基本接口包括:return :: a -> m a
和(>>=) :: m a -> (a -> m b) -> m b
,不过从范畴论(Category Theory)的意义上,可以考虑使用join :: m (m a) -> m a
接口更加易于理解。
Monad的不变结构(即高阶类型m
)具备在不同参数类型下转换的不变性。
可以被抽象为Monad的类型最典型的是状态机例如State s a = State (s, a)
其中State s
具备Monad性质,s
看作是状态,a
看作是可变类型参数。我们可以从状态s
中提取我们需要的类型对象。(具体不展开)
网友评论