感悟篇:如何寫好函式式程式碼

Ljzn發表於2021-01-25

最近在寫程式碼的時候常常感覺迷茫,到底函式式語言應該如何寫程式碼。immutable 資料結構的好處到底在哪裡。為什麼我寫出來的程式碼總感覺像是命令式、過程式的。

帶著這些疑惑,我又重新開始學習了史上最成功的函式式語言 ---- SQL。是的你沒看錯,SQL是一門函式式語言。

https://www.sql-ex.ru/ 這個網站上,你可以做很多免費的 SQL 習題,還有免費的排名,非常爽。

而 SQL 核心的語句是很簡單的,select, from, where 三板斧,輕輕鬆鬆地就能將需求變成查詢語句。

例如這樣一個問題:Find the model number, speed and hard drive capacity of PCs cheaper than $600 having a 12x or a 24x CD drive.

寫出來的 SQL 答案就是:

select model, speed, hd
from PC
where price < 600 and (cd = '12x' or cd = '24x')

簡直就是大白話直譯,依然信達雅齊全。所以我們總結出來,要寫出好的函式式程式碼,你得設計出好的 DSL,或者是好的抽象,好壞的標準就是能否夠直接翻譯產品的需求。而能否用自然語言描述出需求,也是很考驗能力的。所以我們進一步推出:

要成為一個好的函式式程式設計師,你需要成為一個好的產品經理。

匿名函式

匿名函式又被稱作 lambda,可以說是函式式語言的靈魂存在。比如這一段 SQL 語句:

join (...) as t on Product.model = t.model

其實就暗藏了一個匿名函式, 用 JS ES6 來寫, 大概就就是

(Product, t) => Product.model === t.model

join 函式則被稱為高階函式,因為它以匿名函式為引數。

使用匿名函式和高階函式的組合可以增加 DSL 使用者的自由度。

描述一件事而不是做一件事

函式式語言區別於過程式語言的一點是:描述一件事而非去做一件事。有時候你會發現其實很難區分這種區別,因為嚴格意義上來說,語言其實永遠都是在描述一件事。俗話說 “光說不練”,意味著“說” 和 “做” 是相互對立的,而語言並不能實際上去 “做” 。

之所以在一些語言裡,會給我們感覺是 “做” 了什麼,是因為使用了可變資料結構。例如:

let a = 1;
// a = 1
a++;
// a = 2
a *= 2;
// a = 4

可以看到在語言被”說“出來的過程中,變數 a 的值就發生了變化,語言實實在在地 ”做“ 了一些事情 ———— 改變了變數的值。可以這樣讀上面的程式碼:令a為1,令a加1,令a乘以2.

所以,在”只說不做“的函式式語言裡,通常使用的是不可變資料結構。即在語言描述的過程中,不會改變任何的值。例如:

A0 = 1,
A1 = A0 + 1,
A2 = A1 * 2.

% A2 = 4

這樣讀上面的程式碼會更合適:有A0等於1,有A1等於A0加1,有A2等於A1乘以2.

從這個角度來說,我們就更能說明 SQL 是函式式語言,因為它也沒有變數。另外,我們甚至可以說 SVG 也是函式式語言。例如這樣一段 SVG 程式碼:

<polygon points="60,30 90,90 30,90">
        <animateTransform attributeName="transform"
                          attributeType="XML"
                          type="rotate"
                          from="0 60 70"
                          to="360 60 70"
                          dur="10s"
                          repeatCount="indefinite"/>
    </polygon>

描述了一個旋轉的多邊形動畫,在其中你看不到任何變數,只有定義。函式式語言可真是一個懶漢,總是光說不做,所以,要實際做出可以用的東西,一般我們需要底層用命令式的語言去實現,例如將 SQL 解析後轉化成實際的查詢,將 SVG 定義繪製成真正的動畫。所以我們使用函式式語言的時候不要有優越感,因為如果沒有過程式的底層去“做事”,上層的函式式程式碼只能淪為空中樓閣。

把做事的過程隱藏起來

既然函式式語言是 “光說不做”的,那麼要顯得更加函式式,一個方法就是要把做事的過程藏起來。例如看下面這段 React Hooks 的程式碼:

const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );

對於每點選一次按鈕都把 count 的值加1,最容易想到的“做事”方法就是改變 count 的值,即 count++,這函式式嗎?這不函式式。所以仔細看上面的程式碼,從第一行讀到最後一行,count的值變了嗎?沒有。做事的過程被隱藏到了 setCount 裡面,所以這很函式式。

命令的多個出口

曾經有一段時間我以為函式式語言的特徵是函式到最後只有一個輸出。後來我發現其實所謂的一個輸出,是從編譯器的角度去看的。在寫程式碼的時候,每一個帶有副作用的命令,都可以看作是一個出口。

例如這樣一段 React Hooks 程式碼:

import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);

 // Similar to componentDidMount and componentDidUpdate: 
  useEffect(() => { 
  // Update the document title using the browser API 
    document.title = `You clicked ${count} times`; 
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

就能看到 useEffect 這個函式是沒有返回值的,因為它實際上的作用是給 DOM 的觀察者繫結了一個控制程式碼,是一個有副作用的函式。我喜歡這樣理解:函式式的世界是一個純潔的沒有副作用的世界,這些有副作用的函式就是聯通函式式世界和外部世界的大門。門太少擁堵,門太多漏風。

老資歷的函式式語言

嚴格意義上的函式式語言是怎麼寫的,我看了一下這些語言的官網,一般都會有一段最有代表性的程式碼。比如, Haskell:

primes = filterPrime [2..]
  where filterPrime (p:xs) =
          p : filterPrime [x | x <- xs, x `mod` p /= 0]

讀出來就是:“【質數(集合)】等於 【對 2 到正無窮的整數列表 進行 filterPrime 操作】”。至於 filterPrime 操作怎麼讀,就比較拗口了:“【filterPrime操作】 等於 【列表的開頭 p】加上 【對列表的 tail 中不能被 p 整除的數做 filterPrime 操作得到的結果】”。

可以讚歎這種寫法具有數學定義一般的優美準確,也可以對這種因為資料結構不可變而採取的用遞迴替代迴圈,看似不得以而為之方式表示鄙夷。

一般在這些語言裡,型別定義也是可以遞迴的,例如 Ocaml:

type tree = Leaf of int | Node of tree * tree

這裡的 tree * tree{tree, tree} 元祖型別的意思。讀出來就是: “什麼是樹?要麼是一個葉子,要麼是一個節點!什麼是葉子?整數!什麼是節點?樹和樹組成的元祖!”

乍看上去好像迴圈定義了,並沒有。任意一個有限的資料,都可以透過這個型別定義來判斷其是否是一個 tree。因為一直推到到葉子,就可以透過基礎的型別 int 來判斷了。 而不符合這一特性,即“在實際使用時不能最終推導到基本的型別” 的型別定義,即是非法的定義。例如: type a = a .

相關文章