前端理解函数式编程

1,083 阅读10分钟

无论是函数式编程还是命令式编程,追根溯源都属于结构化程序设计。

结构化程序设计

按照结构化程序设计的观点,任何算法功能都可以通过由程序模块组成的三种基本程序结构的组合: 顺序结构、选择结构和循环结构来实现。

这里得重点是只要语法能实现顺序结构、选择结构和循环结构。那这种语法就能搞定任何算法。

jiegou.png

结构化程序设计的两种实践

第一种实践是我们熟知的if else(选择结构)、for while(循环结构)这些语法,顺序结构就是代码自上而下的执行。这通常被称为命令式语法。

第二种实践是函数式,它用自己得方式来实现选择结构和循环结构,顺序结构用函数之间调用来完成。这也被叫做声明式语法。

这里我们要清楚的概念是,命令式语法和函数式语法,都是结构化程序设计的实践。另一个搞清楚的问题是,要想写函数式编程,得抛弃if else / for / while来进行流程控制,因为这些都是属于命令式语法。 chengxu.png

要想研究函数式编程,我觉得用JS来研究会很困难,初期会分不清哪些是函数式的语法,哪些是命令式的语法。需学习一个纯粹的函数式编程语言,才能更好的理解函数式。这里我选择来Haskell。

Haskell是纯函数编程语言型。这里我通过它来介绍函数式。

函数式编程语言中的选择结构

这里不求你看懂Haskell代码的全部语法。能了解这些语法的意图即可,这里说的语法,都是Haskell里怎么实现分支结构的。

模式匹配

函数式语言通过模式匹配能替代if else的功能。

-- 下面代码定义了一个lucky的函数。--是haskell中的注释,相当于js中的//
-- 第一行是函数签名,表示函数接受一个整数,返回一个字符串。
-- 第二行说如果入参是7,则匹配这行作为调用结果,返回"LUCKY NUMBER SEVEN!"
-- 注意Haskell的等号意思跟JS不同,不是赋值,等号只是函数声明的一部分
-- 函数名(入参)=(返回值)
-- 第三行表示如果如参不是7,就匹配这一句。
lucky :: (Integral a) => a -> String  
lucky 7 = "LUCKY NUMBER SEVEN!"  
lucky x = "Sorry, you're out of luck, pal!"  

-- 调用函数luky的代码。lucky 7 会得到 "LUCKY NUMBER SEVEN!"。lucky 100 会得到"Sorry, you're out of luck, pal!"  注意这里函数调用没有括号包裹着参数,这点跟js语法不同。
lucky 7 
lucky 100

守卫

模式匹配可以搞定参数的类型不同,但参数类型相同,但值不同,就需使用守卫语法。

-- 这里依然先写函数签名,表示函数bmiTell接受一个float类型的参数,返回字符串。
-- 入参名字是bmi,如果bmi <= 18.5,就走第一条分支,以此类推。
bmiTell :: (RealFloat a) => a -> String  
bmiTell bmi  
    | bmi <= 18.5 = "You're underweight, you emo, you!"  
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"  
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"  
    | otherwise   = "You're a whale, congratulations!"  

可见守卫可以承载分支逻辑。无论模式匹配还是守卫,都做到了分支结构,还让函数体的逻辑单一(单一职责)。比如bmi <= 18.5后面跟的函数体,只需写这一种情况下的逻辑。

函数体内的if else

模式匹配和守卫,都是函数级别的分支流程控制。下面看看函数体内的分支结构

-- 不是说没有函数式if else这样的命令式语法吗?这里怎么又有了。
-- 在函数级别,确实是没有if else语法的,但在函数内,有。注意这里的if then else跟JS中是有区别的,这里的if then else更像js中的三元表达式,相当于js中amount < 20 ? 0 : 20。
-- 这个函数的意思是:声明一个lend方法,有一个amount入参,如果amount < 20就返回0,否则返回20。
lend amount = if amount < 20
                 then 0
                 else 20

表达式语句 vs 陈述语句:函数式中大部分语句都是表达式语句,也就是得有返回值的语句。而不是JS中(如const a = 10;)这种没有返回值的陈述句。

思考

在js中,没有模式匹配和守卫语法,我们似乎不可避免还是使用命令式的语法if else来实现分支结构。但如果你理解了函数式,就不会拘泥于手段。保证函数都是纯函数,虽然用if else也不制造副作用。

函数式编程语言中的循环结构

Haskell使用递归来实现循环。 写一个replicate函数,重复某元素固定次数,如调用 replicate(3,5) 返回 [5,5,5],意思为重复输出3次5。

Haskell冒号语法:1:[]返回[1]。 2: [1]返回[2,1]。冒号就是将一个值拼接到数组中。

-- 第一行函数签名,表示输入两个int类型的值,返回一个int数组。
-- 第二行声明函数replicate,两个入参重复次数n和重复元素x。
-- 第三行表示如果重复次数小于等于0,返回空数组。
-- 第四行中冒号是Haskell语法,将函数体分为x和replicate (n-1) x两部分,
-- replicate (n-1) x 这里就是递归调用了。这句话的整体意思是x和重复n-1次x拼接(那不就是重复n次x嘛)。
replicate ::  int -> int -> [int]
replicate n x  
    | n <= 0    = []  
    | otherwise = x:replicate (n-1) x

再看看在js中常用的数组map和filter,在haskell中同样使用递归方式实现:

-- map函数实现
map :: (a -> b) -> [a] -> [b]  
map _ [] = []  
map f (x:xs) = f x : map f xs


-- filter函数实现
filter :: (a -> Bool) -> [a] -> [a]  
filter _ [] = []  
filter p (x:xs)   
    | p x       = x : filter p xs  
    | otherwise = filter p xs  

到此,我们看到haskell虽然不用if else / for这样的命令式语句,同样可以完成流程控制。

数据

程序 = 数据 + 算法。

shuju.png

上面说的流程控制,是算法部分。一个语言要想有强大的能力,同样要能操作复杂的数据。 Haskell同样可以构建复杂的对象。

data Customer = Customer {
      customerID      :: CustomerID
    , customerName    :: String
    , customerAddress :: Address
    } deriving (Show)

这说明函数式编程不止可以算‘数’,还可以算对象,让对象在函数之间流转,以达成某种目的。

Haskell中的数据对象,是没有方法的,这点跟面向对象中的对象完全不同。面向对象中的对象是属性+方法组成。Haskell是数据对象是单纯的数据,然后用相应的函数操作它们。

functor(函子)

functor在函数式的知识体系里,属于比较上层的知识点,不好理解,但是我感觉如果只是了解了解函数式的理念,先不看它也罢。

要理解functor,先要了解haskell的类型体系,理解类型类,再累计functor就比较好接受了,我这里只做简单的介绍。

haskell中的数据有类型(Int, String等)的,函数也是由类型的(函数签名如:int -> int -> [int])。

有了类型,haskell又有个类型类的概念,我为了方便理解,可以把类型类理解成面向对象中的基类(事实上不是一回事),类型引用类型类(继承基类)后,就得实现类型类定义的方法。

functor就是有一个fmap函数的类型类。所有实现functor的类型都得实现自己类型的fmap函数。

class Functor f where
    fmap :: (a -> b) -> f a -> f b

函数式编程为啥要定义这样一个类型类呢?答案是有遍历需要的类型太多,试想这样的情况:

我有一个树形结构的数据:
data Tree a = ... 

// 我要为其写遍历的函数treeMap
treeMap :: (a -> b) -> Tree a -> Tree b
treeMap 具体实现
我有一个图形结构的数据:
data Graph a = ... 

// 我要为其写遍历的函数graphMap
graphMap :: (a -> b) -> Graph a -> Graph b
graphMap 具体实现

我要是有一百种数据结构,以此类推,写相应的***Map函数,这样就显得不统一,能不能让所有的map都用一个名字,只是各自类型的具体实现不同呢?最终达到如下效果,统一用fmap方法来遍历那些需要遍历的数据:

-- 定义length方法,它就类似于JS中map函数的那个参数方法,['foo', 'maa'].map(n => n.length)的 n => n.length部分,提供给下面的fmap函数。
length n = n.length

-- 下面这样调用 而不是 treeMap length treeData,length是被map的方法,treeData是被map的数据
 fmap length treeData 
 
 -- 下面这样调用 而不是 graphMap length graphData,length是被map的方法,graphData是被map的数据
 fmap length graphData

可以看到,我们希望用fmap这种通用的方式遍历不同类型的数据,而不是每个类型都有自己的一个map函数的名字treeMap、graphMap、***Map。为达到此目的,haskell定义了类型类Functor,让有遍历需要的数据类型都引用(继承)Functor后完成自己的实现。以便有个统一的fmap函数。

函数式高明的地方在于:我刚才一直将f a中的f解释为Tree、Graph这种数据结构是不全面的,f代表的是函数,Tree、Graph也是一种函数,f还可以是Maybe、List等,更通用的说法是f代表一种上下文场景。

函数式的特点

  • 函数是"第一等公民":让函数也可以作为参数,是函数式编程的基本。
  • 只用"表达式",不用"语句":一个函数或语句,输入些东西,就要输出些东西。
  • 没有"副作用":函数不会产出运算以外的影响。
  • 不修改状态:函数式的世界里,一切数据都是不可变化的,只能产生新的。
  • 引用透明:函数的运行不依赖于外部变量或"状态",只依赖于输入的参数。

函数式编程风格

PointFree风格,亦称无参风格:不使用所要处理的值,只合成运算过程。

有了函数可以作为参数、柯里化为基础,我们就可以编写PointFree风格的代码。Pointfree 就是运算过程抽象化,处理一个值,但是不提到这个值。它让代码更清晰和简练,更符合语义。

函数式能完成一切吗

不能。 函数式要想跟键盘和屏幕交流,需要依赖有副作用的函数,用有副作用的函数将值传递给函数式范式里的纯函数,再将计算出的结果,在有副作用的函数里显示到屏幕上。

我们日常用的函数式库,也是如此,从命令式的语法里拿数据给函数式的库计算,得到的值,还是交给命令式的语法来继续后续操作。

面向对象 or 函数式

  • 在接近业务方面的编程,使用面向对象范式,面向对象是一种对现实世界的抽象。
  • 在接近数学公式、流式操作方面的编程,用函数式比较合适。

在前端编程中,面向对象和函数式是并存的。

JS语言在支持函数式上的缺陷

静态类型

我觉得(个人观点)静态类型还是挺重要的,它是模式匹配的基础,模式匹配承载了一部分支结构的功能。

Haskell基于静态类型,有了类型、类型类这些上层建筑,使语法有了更强的语义。

函数签名(指bmiTell :: (RealFloat a) => a -> String)

函数签名在函数式中有着很重要的作用。函数式编程最重要的是函数,函数参数可以是对象也可以是函数,这让函数更加强大,也增加了函数的复杂性。如果没有函数签名,会让使用这个函数的人很困惑,不知怎样调用它,传什么样的参数合适。

  • Haskell中可以轻松查看一个函数的签名,知道给它传什么样的参数合适。
  • JS在语法上是不支持函数签名的。

前端的一些函数式

我们就来看看前端中存在的一些函数式思想。

  • JS数组操作:map、filter、reduce都是函数式的思路。

  • react中的函数式

    • 高阶组件:是一个没有副作用的纯函数。(很函数式)
    • Redux:Redux本身还有添加中间件的能力,都是函数式思想的体现。
  • Ramda: 函数式编程风格而设计的JS数据操作库。 我写过的相关文章:【译】为什么使用 Ramda【译】 Ramda函数签名

  • Rxjs:函数式编程是 Rx 最重要的观念之一, Rxjs是一个非常强大的异步操作库。