Haskell 随笔一 函数式编程

前言

本文记录一下个人学习Haskell的一些随笔,记录一下学习Haskell过程中的一些有趣的东西

Haskell语言的思维可能颠覆绝大多数鱼友对编程语言的认知

因为是随笔,可能继续更新也可能不继续更新

随笔一 函数式

说Haskell永远绕不开Haskell这个语言本身强调的东西,那就是纯函数式编程^1

函数式编程是一个古老的编程思想,最早可以追溯到第一台计算机出现的时候(有兴趣的可以自行去了解)

题外话:最早的函数式语言是lisp, lisp至今仍然活跃

什么是函数式编程呢?

顾名思义,函数式编程范式强调函数的合法性,将函数视为第一公民^2(后续叫做头等函数),可以像其他的数据一样可以作为形参,返回值,可以赋值给变量,也可以存储到数据结构中。以及延伸出了一个新的概念高阶函数^3,也就是匿名函数闭包

function add(x, y) {
   return x + y
}

function twoNum(fn) {
     return fn(10, 20)
}

twoNum(add) // 可以传递

function getAdd() {
      return add   // 可以当作返回值
}

const Add = add // 可以赋值

// 匿名函数
const mod = (x, y) => x % y
const div = function (x, y) {
   return x / y
}

// 闭包
function shift(y) {
   var x = 2
   return () => {
     return y << x
   }
}


函数式还强调纯函数以及排斥副作用

当一个状态是可以改变的,就被称之为副作用

什么意思呢?

int a = 0;
a = 1;
a = 2;
printf("%d", a);

这里打印什么呢?没错打印2, 这个变量a在开始的时候值是0,也就是状态是0,在后续中,修改了状态,状态被修改成了1, 状态被修改成了2,然后打印。这就是函数式语言所强调的副作用

一段更常见的副作用的代码

for (int i = 0; i < 5; i++) 
{
    // do something
}

这里循环会一直对变量i的状态进行修改,所以循环是副作用

函数式编程中是对副作用进行排斥的,变量更符合数学上的变量,只是代表任意值而已,变量只能绑定初始化,并不能对它进行修改

说完副作用来说说纯函数

函数只是计算结果,并不会修改任何其他状态,函数输出的结果只会依赖输入的值,输入相同的值永远都是输出对应的结果,跟数学中的函数一样f(x) = 1 + x,输入f(1)得到的结果永远是2,不会是3,也不会是4,更不会是1. 函数式中把这样的函数称之为纯函数

虽然函数式编程中才强调纯函数,但是纯函数并不是只有函数式编程语言才会有的,以下是一个c的代码

int add(int i)
{
   int x = 0;
   x = 1;
   return i + x;
}

说说看这里是不是纯函数?是的它是,它虽然修改了变量,但是它是一个不折不扣的纯函数

回去看看纯函数的定义,函数不会修改任何其他的状态,函数输出的结果只会依赖输入的值,这个c的函数它的结果也是只会以来输入的值,输入add(1)得到的结果永远是2,不会是3,也不会是4,更不会是1。所以,这个函数是一个纯函数,对于每次输入都得到不同输出的函数,称之为不纯函数

可能会有人问,“函数是一等公民能理解,那这个纯函数和排斥副作用有什么用呢?纯函数都不能传递指针。排斥副作用连循环都做不到,我想修改一些东西,还不能修改,一昧的强调纯还有副作用岂不是什么都干不了?“

说的没错,函数式对纯和副作用是暧昧的,即排斥也不排斥,因为副作用是必不可少的,比如io, io就是一个不纯,充满副作用的操作,比如我打开一个文件,这一次打开后里面是一个字符串,下次打开可能就不是这个字符串了,再比如发送一个网络请求,收到的结果都可能不是一个消息、数据,还有数据库对接等等等等等等。。。

这些都是无法避免的,所以函数式编程也不是那么的一昧强调,函数式编程只在自己可控的地方保持,在自己可控的时候保持纯将会大大的提高开发的效率,因为纯的原因,很容易定位自己出错的地方,比如f(x) = 1 + x当调用了这个f函数f(1)结果不等于2,我们就可以知道这个函数出错了,而不是一个一个的排查是哪个出错了。

在函数式中迭代是用的递归,而不是用的循环,用递归避免了状态的改变

就像前面的循环

for (int i = 0; i < 5; i++)
{
    // do something
}

递归就是

int loop(int i)
{
  if (i >= 5)
     return;
    // do something
}

使用递归迭代,而不是用循环来迭代

修改与过程式的思维也不太一样

比如这里有一个过程式数组的修改

int []a = {1, 2, 3, 4, 5};
// 修改
a[1] = 3;
// a == {1, 3, 3, 4, 5} True

函数式编程的修改是copy一个副本,然后对响应的位置修改

int []a = {1, 2, 3, 4, 5};
int []b = copy(a);
b[1] = 3;
// a == {1, 3, 3, 4, 5} False
// b == {1, 3, 3, 4, 5} True

这种一般都在语言层面搞定了,不需要开发者去操心,改变的只是新值,并不是原值

并且有大量的优化,比如你要是想修改下标1的值,则会创建一个新的数组,这个数组的下标1的位置是你要修改的值,所以这样并没有进行修改,而是直接创建了一个新的值,相当于这样

int []a = {1, 2, 3, 4, 5};
a[1] = 3; // 这个时候就直接创建一个{1, 3, 3, 4, 5}的数组

函数式的操作的一切都是copy值,而不是原值,这样可以在自己可控的范围里做到纯和排斥副作用

函数式属于声明式的范畴,更愿意用一百个函数去抽象事务,而不是用过程去描述事务,也不是使用对象去抽象事务。

纯保证了函数的可靠性,函数就像是一个可靠的加工机器,我放入我要加工的原材料,得到我想要加工的结果,我不需要关心里面是如何实现的,但是我得到了我想要的结果。假如我有一个研磨机,我倒入咖啡豆,得到的只会是咖啡粉,而不是绿豆粉红豆粉,更不是什么洋娃娃trollface 。这样我只需要再给研磨机加上个电池,就可以实现自动研磨机而不是手动去磨了,再把自动研磨机封装到热水机里就可以得到一个咖啡机了,函数的组合也是如此,将多个不同的功能的函数组合到一起就可以得到自己想要的东西,而不是像过程式一样去一遍又一边的去描述我应该打开咖啡豆的罐子,怎么把咖啡豆放到研磨机,怎么把电池放到研磨机里,怎么磨咖啡豆,怎么烧水,怎么冲咖啡,也不像面向对象那样要去描述一个咖啡豆是什么样,研磨机器是怎么样,电池是什么样,热水机是什么样,然后再把过程式的步骤重复一边

1: 现代函数式编程被分类为两个,纯和非纯

2: 是的在早期的编程语言中,大部分语言中函数并不是第一公民,只是一个代码块

3: 这里将高阶函数只归类成匿名函数和闭包是不严谨的,但是这里只是随笔!随笔!

随笔一 结言

是不是感觉现代大部分语言都有这些范式特性?pythongolangrustjavascriptjava8+php,但这些语言都并非真正意义上的函数式编程语言,他们都是过程式或面向对象的语言,只是他们借鉴(抄)了函数式编程的思想,将这些概念柔和到了一起,兼容了这些特性,形成了如今包含了过程式,面向对象,函数式等大杂烩的现代多范式语言。

本文也不是在说函数式优于过程式,还有面向对象,它也有缺点,比如没完没了的复制新值导致性能的缺失(还有很多,但本文只说这一点)。还有如今的计算机都是过程式的硬件范畴,所以过程式的编程语言性能都很高,比如汇编以及c语言。面向对象的语言是过程式语言的扩展,在过程式的基础上添加了对象的抽象,所以也有不俗的性能以及提高了程序的抽象能力,现在的函数式语言也都翻译成过程式进行运行,因为计算机架构的原因这是没办法避免的。所以函数式既有缺点也有优点,各个编程范式都有自己的优缺点,在我本人看来每个编程语言范式都是平等平价的


虽然是说是Haskell随笔,但是随笔一作为第一次提笔,就只介绍了一下函数式编程的思维,并没有说Haskell太多相关的知识,只能说先挖个坑后面看心情填了trollface