1. 程式人生 > >關於JavaScript函數語言程式設計的思考

關於JavaScript函數語言程式設計的思考

前幾天看到掘金上有兩篇關於JavaScript函數語言程式設計的爭論,有人建議不用for迴圈,有的人又說太過函式式不好。我自己也是一個喜歡函數語言程式設計的人,所以寫了這篇文章想和大家分享一些我個人喜歡的建議,最後也有一些我自己的思考。

第一次在掘金髮文章,各位同行手下留情,有錯誤歡迎指出。

1、給函式清晰的命名,寫好函式功能介紹以及函式簽名。

好的函式名稱能夠清晰地讓人知道這個函式的功能作用。

函式簽名能夠讓你知道某個函式會對引數做怎樣的型別轉換,當你使用函式組合時這個功能尤其有用。

不是很清晰的命名:

const a1 = (value) => value.name;
const a2 = (value) => value > 10;
複製程式碼

清晰一些的函式命名和函式簽名:

// 接受一個userObj, 返回userObj.name
// Object -> *
const getName = (userObj) => user.name;

//接受一個Number, 判斷該Number是否大於10
// Number -> Boolean
const isGreaterThan10 = (number) => number > 10;

複製程式碼

2、嘗試多使用函數語言程式設計方式,少使用指令式程式設計方式。

通常函數語言程式設計方式比指令式程式設計方式更清晰直觀,學會抽象和封裝一些常用功能函式,減少命令式的程式碼,多使用宣告式的程式碼。

使用for迴圈

// 將test裡面的值翻倍
const test = [1,2,3,4,5];
// 快取陣列長度
const length = test.length;
// 陣列索引
let i = 0;
// 儲存翻倍後的新陣列到result
let result = [];
for(i; i < length; i++) {
    result[i] = test[i] * 2;
}

console.log(result);

複製程式碼

使用內建map函式或第三方map函式。

// 將test裡面的值翻倍
const test = [1,2,3,4,5];
const result = test.map(item => item * 2);

console.log(result);
複製程式碼

3、嘗試定義函式時採用分散的方式,使用函式時採用組合的方式。

定義函式時採用分散的方式可以把各個單獨的功能點拆分出來,在需要時可以複用該功能函式。

使用時再根據需要把各個小功能函式組合起來。

所有邏輯寫在一個函式裡面。

// 獲取user裡面的name,大寫首字母,然後加上'hello, '字首。

/*
**接受一個user Object,獲取裡面的name,大寫name首字母,然後新增hello字首,
**最後再返回改變後的字串。
** Object -> String
*/
const changeName = (user) => {
    const name = user.name;
    const capitalFirstLetter = name[0].toUpperCase();
    const restName = name.slice(1);
    const changedName = `Hello, ${capitalFirstLetter}${restName}`;
    return changedName;
}
// 測試
const user = {
    name: 'alex',
};
const result = changeName(user);

console.log(result);
複製程式碼

分開定義不同的功能函式,需要時再組合起來

/*
** 接受一個user Object, 返回user.name
** Object -> *
*/ 
const getName = (user) => user.name;

/*
** 接受一個word String, 返回大寫該word首字母后的字串
** String -> String
*/
const capitalizeFirst = (word) => {
    const capitalFirst = word[0].toUpperCase();
    const rest = word.slice(1);
    return capitalFirst + rest;
};

/*
** 接受一個word String, 返回新增hello字首後的字串
** String -> String
*/
const addHello = (word) => `Hello, ${word}`;

/*
**這裡定義了一個幫助函式,pipe函式用於幫助組合函式,pipe接受3個一元函式fn1,fn2,fn3,返回一個新函式。
**呼叫新函式時傳遞的引數會依次傳遞給fn1,fn2,fn3,
**每次接受的引數是上一次呼叫函式後的返回值。
**(((a -> b), (b -> c), …, (x -> y), (y -> z)) → ((a, b, …, n) -> z)
*/
const pipe = (fn1, fn2, fn3) => (value) => fn3(fn2(fn1(value)));

// 測試
const user = {
    name: 'alex',
};

/*
**這個組合函式接受一個物件,然後通過組合的所有中間函式
**把資料轉化成我們希望的結果
** 注意 pipe(getName, capitalizeFirst, addHello)(user) 
** 等於 addHello(capitalizeFirst(getName(user)))
** Object -> String
*/
const changeName = pipe(
    // 獲取name
    getName,
    // 大寫首字母
    capitalizeFirst,
    // 新增hello字首
    addHello
);
const result = changeName(user);

console.log(result);

複製程式碼

4、多使用純函式,純函式意味著你傳入相同的引數,總會返回相同的結果,不會依賴外部變數。

純函式有很多好處,方便測試,可以併發呼叫,方便做快取功能。建議在可能的情況下多使用純函式。

// addPrefix是不純的函式,依賴了外部變數prefix,傳入相同的name也可能因為外部prefix的值而返回不同的結果
const prefix = 'hello, ';

// 接受一個name字串,返回新增字首後的字串
// String -> String
const addPrefix = (name) => prefix + name;

/* 純函式,傳入相同的prefix 和 name肯定會返回相同的結果
** 接受一個name字串,和一個字串字首prefix,返回新增字首後的字串
** String -> String
*/
const addPrefix = (prefix, name) => prefix + name;

複製程式碼

5、學會使用函式柯里化(currying)和部分應用(partial application)技術減少重複程式碼和組合程式碼。

在上面例子中,有幾個地方用的固定引數,比如getName就是接受一個user物件,然後返回這個物件的name值,如果我們想獲取user的年齡age值,我們需要重新寫一個getAge,如果需要獲取使用者性別sex,我們可能又需要寫一個getSex函式。

下面是可能的程式碼:

const getName = (user) => user.name;
const getAge = (user) => user.age;
const getSex = (user) => user.sex;

複製程式碼

這樣的3個函式都是類似的,都是獲取一個物件裡面的某個屬性值。

這種情況你可能會寫一個二元函式,這個函式會接受2個引數,第一個是一個Object,第二個是想獲取的key值的名稱,下面是可能的程式碼:

const getProp = (obj, key) => obj[key];

const name = getProp(user, 'name');
const age = getProp(user, 'age');
const sex = getProp(user, 'sex');
複製程式碼

這幾個函式還是有一些共同點,都是從user中獲取屬性,而且,2元函式沒辦法和pipe函式很好的結合起來,因為在組合中我們pipe函式每次只會返回和傳遞單個值到下一個函式。

那麼有沒有什麼更好的辦法呢?我們可以使用到函式柯里化(currying)和部分應用(partial application)技術。

函式柯里化(currying):

函式柯里化的意思是把一個多元函式轉化成每次接受一個引數,但是需要多次呼叫才能獲取結果的函式,下面是2個簡單例子:

// 沒有柯里化的add函式
const add = (a, b, c) => a + b +c;
const result = add(1, 2, 3);
console.log(result) // 6
// 柯里化後的curryAdd函式
const curryAdd = (a) => (b) => (c) => a + b + c;
const result = curryAdd(1)(2)(3); 
console.log(result); // 6
//

複製程式碼

使用沒有柯里化的add函式時,你必須一次性傳遞3個引數,只調用一次函式,而使用curryAdd函式時,你每次傳遞1個引數,但是呼叫了3次函式,curryAdd就是柯里化後的函式。

回頭看看getProp函式:

const getProp = (obj, key) => obj[key];
複製程式碼

這裡的getProp需要一次性接受2個引數,我們現在實現一個柯里化的getProp函式,讓它每次接受一個引數:

const curryGetProp = (key) => (obj) => obj[key];
複製程式碼

下面是2個函式的不同調用方式

const user = {
    name: 'alex',
    age: 22,
    sex: 'boy',
};

// 沒有柯里化的getProp
const name = getProp(user, 'name');

// 柯里化的curryGetProp
const name2 = curryGetProp('name')(obj);
複製程式碼

柯里化後curryGetProp需要呼叫兩次,總共分別傳遞2個引數才能獲取最終結果,但是我們並不一定要一次性呼叫2次,我們可以分開呼叫:

//連續呼叫時的情況
const name = curryGetProp('name')(user);
const age = curryGetProp('age')(user);
const sex = curryGetProp('sex')(user);

//分2次呼叫,中間儲存到一個變數
const getName = curryGetProp('name');
const name = getName(user);

const getAge = curryGetProp('age');
const age = getAge(user);

const getSex = curryGetProp('sex');
const sex = getSex(user);

複製程式碼

你可以發現我們把原來必須一次性處理的步驟分成了2個小部分,而且我們可以更靈活地組合:

// 生成一個獲取物件name的函式
const getName = curryGetProp('name');
// 生成一個獲取物件age的函式
const getAge = curryGetProp('age');
// 生成一個獲取物件sex的函式
const getSex = curryGetProp('sex');

// 獲取user的name屬性
const name = getName(user);
// 獲取user的age屬性
const age = getAge(user);
// 獲取user的sex屬性
const sex = getSex(user);

複製程式碼

這樣做的好處:

// 可以很方便地複用curryGetProp新增獲取其他屬性的函式
const getHobby = curryGetProp('hobby');
const getWeight = curryGetProp('weight');

// 可以獲取不同物件(user1,user2,user3)的相同屬性('name')
const getName = curryGetProp('name');

const name1 = getName(user1);
const name2 = getName(user2);
const name3 = getName(user3);

// 可以很方便和其他高階函式結合,如map
const userList = [
{
    name: 'alex',
    age: 22,
},
{
    name: 'irina',
    age: 18,
},
];
const getName = curryGetProp('name');
const nameList = userList.map(getName);
console.log(nameList); // ['alex', 'irina']

// 可以和pipe結合,配合上面我們提到的其他函式
const getName = curryGetProp('name');
const changeName = pipe(
    getName,
    capitalizeFirst,
    addHello
);

複製程式碼

使用柯里化,你可以有更多的方式去拆分和組合你的函式,你也有更多的選擇去組織和複用你的程式碼。

部分應用(partial application)

上面getProp就是一種部分應用,我們預先傳遞了部分引數,然後再需要時複用它,你可以看到部分應用其實是和柯里化結合使用的,這裡,我們再換種方式,首先重新來看看上面的curryGetProp。

const curryGetProp = (key) => (obj) => obj[key];
複製程式碼

在這裡,第一次呼叫函式時首先傳遞了key,然後第二次呼叫時再傳遞的obj。

這裡可以看作我們是在部分應用key,我們先傳遞一個key,過後傳遞不同的obj來獲取相同的key的屬性。

// 接受不同obj(user1,user2,user3),獲取相同屬性('name')
const getName = getProp('name');

// 這裡分別獲取了user1,user2,user3裡面的name屬性
const name1 = getName(user1);
const name2 = getName(user2);
const name3 = getName(user3);
複製程式碼

現在我們反過來,我們部分應用obj,然後傳遞不同的key值獲取不同的屬性。

const getPropFrom = (obj) => (key) => obj[key];

// 接受相同obj(user1),獲取不同屬性('name','age','sex')
const getPropFromUser1 = getPropFrom(user1);

const user1Name = getPropFromUser1('name');
const user1Age = getPropFromUser1('age');
const user1Sex = getPropFromUser1('sex');
複製程式碼

同樣地,getPropFromUser1也可以和其他函式結合:

// 和map結合
const user1 = {
    name: 'alex',
    age: 22,
    sex: 'boy',
};
const keyList = ['name', 'age', 'sex'];

const getPropFrom = (obj) => (key) => obj[key];
const getPropFromUser1 = getPropFrom(user1);
const user1Info = keyList.map(getPropFromUser1);

console.log(user1Info); // ['alex', '22', 'boy']

// 和pipe結合
const user1 = {
    name: 'alex',
    age: 22,
    sex: 'boy',
};
const getPropFrom = (obj) => (key) => obj[key];
const getPropFromUser1 = getPropFrom(user1);
const changeName = pipe(
    getPropFromUser1,
    capitalizeFirst,
    addHello
);

const result = changeName('name');
console.log(result); // 'Hello, Alex'
複製程式碼

5、純函式與函式快取。

上面提到過,給純函式相同的引數,總會獲得相同的結果,如果有一個消耗比較大的純函式,那麼如果我們連續呼叫幾次都傳遞相同的引數,我們可以快取下第一次呼叫後的結果,後面每次呼叫都直接返回快取的資料。

/*
** memoize 幫助函式接受一個函式,返回一個具有快取功能的函式。
** Function -> Function
*/
const memoize = (fn) => {
  // 用於快取資料
  let cache = {};
  // 快取Array原型鏈上slice方法
  const slice = Array.prototype.slice;

  return function() {
    // 獲取函式的所有引數並放在一個數組裡面。
    const args = slice.call(arguments);
    // 轉換成字串
    const str = JSON.stringify(args);
    // 檢測是否已經有快取了,有的話直接返回快取的資料,否則呼叫函式獲取
    cache[str] = cache[str] || fn.apply(fn, arguments);
    //返回資料
    return cache[str];
  }
}

// hugeArray是一個非常大的陣列,真的很大。
const hugeArray = [1,2,3, ...];

/*
**翻倍一個數字數組裡面的數字
** Array -> Array
*/
const doubleHugeArray = (numberArray) => {
  return numberArray.map(number => number * 2)
}

// 這裡3次呼叫每次都會重新遍歷陣列
const result1 = doubleHugeArray(hugeArray);
const result2 = doubleHugeArray(hugeArray);
const result3 = doubleHugeArray(hugeArray);

const memoizedDoubleHugeArray = memoize(doubleHugeArray);

//這裡只有第一次會遍歷,後面2次呼叫都是直接返回快取的結果
const result1 = memoizedDoubleHugeArray(hugeArray);
const result2 = memoizedDoubleHugeArray(hugeArray);
const result3 = memoizedDoubleHugeArray(hugeArray);
複製程式碼

下面是一個結合了上面技巧的例子,注意這個例子為了演示作用,刻意使用了函數語言程式設計方式,真實情況中可根據可讀性等適當調整,例子中使用了lodash類庫的一些現成函式:

// 獲取test資料裡面的answer為'是'的所有元素的id,用逗號拼接成字串。
var test = [
  [
    {
      answer: '是',
      id: 1,
    },
    {
      answer: '是',
      id: 2,
    },
    {
      answer: '否',
      id: 3,
    },
  ],
    [
    {
      answer: '是',
      id: 4
    },
    {
      answer: '否',
      id: 5
    },
    {
      answer: '否',
      id: 6
    },
  ]
];

// 接受一個Object,檢測該物件Object.answer屬性是否等於“是”。
// Object -> Boolean
const getAnswerYes = (item) => (_.get(item,'answer') === '是');

// 接受一個Object陣列,返回Object answer屬性為“是”的所有元素組成的一個新陣列
// [Object] -> [Object]
const filterAnswerYes = _.partialRight(_.filter, getAnswerYes);

// 接受一個Object,返回Object.id
// { id: value} -> value | undefined
const getIdProp = _.property('id');

// 接受一個Object陣列, 返回每個Object的Id組成的一個新陣列
// [Object] -> [String | Number]
const mapIdProp = _.partialRight(_.map, getIdProp);

// 接受一個包含id的陣列[id], 用“,”拼接成字串,返回拼接後的字串。
// [id] -> String
const joinId = _.partialRight(_.join, ',');

// 這個組合函式用於把資料轉化成所需結果
// [a] -> String
const getSelectedIdString = _.flow([
  // 展開資料成一維陣列
  _.flattenDeep,
  // 過濾出選項為“是”的元素組成的陣列
  filterAnswerYes,
  // 獲取所有元素中的id組成的陣列
  mapIdProp,
  // 將所有id用逗號連線成字串
  joinId
]);

var result = getSelectedIdString(test);
console.log(result); // "1,2,4"

複製程式碼

一些可能對你有用的思考:

當我寫這篇文章時,我是想表達什麼?

首先,你可以完全不用甚至不認同上面的觀點和方式,也許在某些場景下上面的觀點並不是好的方式,甚至是比較壞的方式,你的觀點應該由你自己做決定。這裡我只是想說下我自己的觀點:

在我看來,學習函數語言程式設計最大的好處反而不是你掌握的關於函數語言程式設計技巧本身,而是在學習過程中的思考過程。

比如說用類似map,foreach的方式替換for迴圈,重點是哪種方式更好嗎?在我看來不是,重點是你在用map等高階函式替換for迴圈過程中,你其實是在把一些常用功能抽象整理出來,而抽象這個能力是很重要的,因為在處理越來越複雜的事物中,你沒辦法確保你總能從最開始一步一步做到結尾,這個時候你需要把一些細小的思想抽象成一個整體,然後在以後需要時候再把各個整體抽象成更大的整體,不用每次都從零開始考慮細節,而這種能力,在你用其他程式設計正規化如面向物件時也是很重要的。 就好像數學一樣,你肯定不會每次算直角三角形斜邊都想自己證明一下勾股定理。

而對於拆分和組合函式,比起你不思考就直接編寫程式碼,在拆分和組合函式過程會強迫你先思考,思考各個事物之間的聯絡,這個能力也是很重要的,在現實中,錯誤的判斷了事物之間的聯絡可能會導致嚴重的後果,就像上了不該上的車,等了不該等的人。在程式設計中也是一樣,準確分析判斷事物的聯絡在我看來也是比較重要的,我需要把這個邏輯拆分成幾個小函式嗎?我應該根據什麼依據來拆分?不拆分會對以後修改造成嚴重影響嗎?這種解決問題前的分析思考是很重要的。

更重要的是,我發現學習函數語言程式設計能激發你的思考,會帶給你一些超越程式設計本身的東西,比如你瞭解了函式組合,你知道每個中間的組合函式都必須是一元函式,因為每個函式只能返回一個引數給下一個函式,那麼你可能會想如果遇見多元函數了怎麼辦?然後你發現了函式柯里化和部分應用好像可以解決這個問題,於是你可能就會去了解它們,同樣地,你在手動柯里化一個函式時,你又可能會想,難道我需要每次都手動柯里化嗎?能不能實現一個自動柯里化任意引數函式的幫助函式?最後你可能會手動去實現它,這個過程中你可能又會產生新的想法,發現重點沒有?順著這種方式你會思考很多,也會收穫很多,而這種思考的過程,也會對你以後學習其他東西有很大幫助。

隨便舉幾個可以思考的例子:

1、柯里化一個函式中,我們需要儲存對每個引數的引用,並在最後時刻使用它們,那麼這個引用是怎麼儲存的?然後你思考後可能就發現是因為我們返回的函式可以讀取到定義它時外部函式的變數,你再一觀察思考,這好像不就是困擾我比較久的閉包知識點嗎?如果不使用閉包,能用其他方式儲存變數嗎?

2、上面我們提到了純函式和快取,你知道我們可以快取一個純函式,你也學習了函式組合,那麼你想一下,一堆純函式組合的函式是不是也是純函式?那麼這個組合出來的純函式是不是也可以被快取?假如你組合了一堆函式,使用快取後的組合函式關鍵時刻能不能幫你節省效能?

你可以發現,其實從一些看似簡單的東西,如果你多思考,就能收穫很多東西。到這裡,我不知道你有沒有發現一些比程式設計本身更重要的東西,思考和不要自我設限

你可能還沒有意識到,在你太偏向於某種固定方式時,你就已經在給自己設限了,如果太過於在於某件事物的壞處,你就很難從它上面獲取到有利的東西,如果你太偏向於函式式,那麼你可能比較直接地否定一些面向物件的技巧,反過來也一樣,你有沒有這樣想過,我喜歡函數語言程式設計,如果某天我用的語言不支援函數語言程式設計怎麼辦?為什麼不兩種方式都瞭解一下?或者其他更多的方式,甚至你自己也可以試著總結出你自己的方式,說不定就是下一個流行的程式設計正規化。

生活給了我們很多限制,程式語言給了我們很多限制,希望大家不要再給自己設限,保留一種開放的心態,求同存異,共謀發展。

加油哥麼!