天天看點

前端必學——函數式程式設計(四)

本篇會将談談函數程式設計中一個很重要的細節 —— “副作用”。

前端必學——函數式程式設計(四)

維基上關于副作用的解釋:

函數内部有隐式(Implicit)的資料流,這種情況叫做副作用(Side Effect)。

咱們前文也提到過:開發人員喜歡顯式輸入輸出而不是隐式輸入輸出。

是以我們将細緻的看看副作用中【隐式】和【顯式】的差別!

何為副作用?

先來個小例子作開胃菜:

// 片段 1
function foo(x) {
    return x * 2;
}

var y = foo( 3 );

// 片段 2
function foo(x) {
    y = x * 2;
}

var y;

foo( 3 );
      

片段 1 和片段 2 實作的最終效果是一緻的,即 y = 3 * 2 ,但是片段 1 是顯示的,片段 2 是隐式的。

原因是:片段 2 在函數内引用了外部變量 y。

片段 2 ,當我們調用 foo( 3 ) 時,并不知道其内部是否會修改外部變量 y。它的修改是隐式的,即産生了副作用!

有副作用的函數可讀性更低,我們需要更多的閱讀來了解程式。

再舉一例:

var x = 1;

foo();

console.log( x );

bar();

console.log( x );

baz();

console.log( x );
      

如果每個函數内都引用了 x ,有可能對其指派修改,那麼我們很難知道每一步 x 的值是怎樣的,要每一步去追蹤!

選擇在一個或多個函數調用中編寫帶有(潛在)副作用的代碼,那麼這意味着你代碼的讀者必須将你的程式完整地執行到某一行,逐漸了解。

如果 foo()、bar()、和 baz() 這三個函數沒有(潛在)副作用,x 的值一眼可見!

一定是修改外部變量才是産生副作用了嗎?

function foo(x) {
    return x + y;
}

var y = 3;

foo( 1 ); 
      

這段代碼中,我們沒有修改外部變量 y ,但是引用了它,也是會産生副作用的。

y = 5;

// ..

foo( 1 );   
      

兩次 foo( 1 ) 的結果卻不一樣,又增大了閱讀的負擔。相信我,這是個最簡單抽象的例子,實際的影響将遠大于此。

避免副作用?

  1. const

以上面的例子來說:這樣寫,foo( 1 ) 的結果當然是确定的,因為用到了 const 來固定外部變量。

const y = 5;

// ..

foo( 1 );
      
  1. I/O

一個沒有 I/O 的程式是完全沒有意義的,因為它的工作不能以任何方式被觀察到。一個有用的程式必須最少有一個輸出,并且也需要輸入。輸入會産生輸出。

還記得 foo(..) 函數片段 2 嗎?沒有輸出 return,這是不太可取的。

// 片段 2
function foo(x) {
    y = x * 2;
}

var y;

foo( 3 );
      
  1. 明确依賴

我們經常會由于函數的異步問題導緻資料出錯;一個函數引用了另外一個函數的回調結果,當我們作這種引用時要特别注意。

var users = {};
var userOrders = {};

function fetchUserData(userId) {
    ajax( "http://some.api/user/" + userId, function onUserData(userData){
        users[userId] = userData;
    } );
}

function fetchOrders(userId) {
    ajax( "http://some.api/orders/" + userId, function onOrders(orders){
        for (let i = 0; i < orders.length; i++) {
                // 對每個使用者的最新訂單保持引用
            users[userId].latestOrder = orders[i];
            userOrders[orders[i].orderId] = orders[i];
        }
    } );
}
      

fetchUserData(..) 應該在 fetchOrders(..) 之前執行,因為後者設定 latestOrder 需要前者的回調;

寫出有副作用/效果的代碼是很正常的, 但我們需要謹慎和刻意地避免産生有副作用的代碼。

  1. 運用幂等

這是一個很新但重要的概念!

從數學的角度來看,幂等指的是在第一次調用後,如果你将該輸出一次又一次地輸入到操作中,其輸出永遠不會改變的操作。

一個典型的數學例子是 Math.abs(..)(取絕對值)。Math.abs(-2) 的結果是 2,和 Math.abs(Math.abs(Math.abs(Math.abs(-2)))) 的結果相同。

幂等在 js 中的表現:

// 例 1
var x = 42, y = "hello";

String( x ) === String( String( x ) );                // true

Boolean( y ) === Boolean( Boolean( y ) );            // true

// 例 2
function upper(x) {
    return x.toUpperCase();
}

function lower(x) {
    return x.toLowerCase();
}

var str = "Hello World";

upper( str ) == upper( upper( str ) );                // true

lower( str ) == lower( lower( str ) );                // true

// 例 3
function currency(val) {
    var num = parseFloat(
        String( val ).replace( /[^\d.-]+/g, "" )
    );
    var sign = (num < 0) ? "-" : "";
    return `${sign}$${Math.abs( num ).toFixed( 2 )}`;
}

currency( -3.1 );                                    // "-$3.10"

currency( -3.1 ) == currency( currency( -3.1 ) );    // true
      

實際上,我們在 js 函數式程式設計中幂等有更加寬泛的概念,即隻用要求:f(x) === f(f(x))

// 幂等的:
obj.count = 2; // 這裡的幂等性的概念是每一個幂等運算(比如 obj.count = 2)可以重複多次
person.name = upper( person.name );

// 非幂等的:
obj.count++;
person.lastUpdated = Date.now();

// 幂等的:
var hist = document.getElementById( "orderHistory" );
hist.innerHTML = order.historyText;

// 非幂等的:
var update = document.createTextNode( order.latestUpdate );
hist.appendChild( update );
      

我們不會一直用幂等的方式去定義資料,但如果能做到,這肯定會減少意外情況下産生的副作用。這需要時間去體會,我們就先記住它。

純函數

你應該聽說過純函數的大名,我們把沒有副作用的函數稱為純函數。

例 1:

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

輸入(x 和 y)和輸出(return ..)都是直接的,沒有引用自由變量。調用 add(3,4) 多次和調用一次是沒有差別的。add(..) 是純粹的程式設計風格的幂等。

例 2:

const PI = 3.141592;

function circleArea(radius) {
    return PI * radius * radius;
}
      

circleArea 也是純函數。雖然它調用了外部變量 PI ,但是 PI 是 const 定義的常量,引用常量不會産生副作用;

例 3:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}
      

unary 也是純函數。

表達一個函數的純度的另一種常用方法是:給定相同的輸入(一個或多個),它總是産生相同的輸出。

不純的函數是不受歡迎的!因為我們需要更多的精力去判斷它的輸出結果!

寫純函數需要更多耐心,比如我們操作數組的 push(..) 方法,或 reverse(..) 方法等,看起來安全,但實際上會修改數組本身。我們需要複制一個變量來解耦(深拷貝)。

函數的純度是和自信是有關的。函數越純潔越好。制作純函數時越努力,當您閱讀使用它的代碼時,你的自信就會越高,這将使代碼更加可讀。

其實,關于函數純度還有更多有意思的點:

思考一個問題,如果我們把函數和外部變量再封裝為一個函數,外界無法直接通路其内部,這樣,内部的函數算不算是一個純函數?

階段小結

  1. 我們反複強調:開發人員喜歡顯式輸入輸出而不是隐式輸入輸出。
  2. 如果有隐式的輸入輸出,那麼就有可能産生副作用。
  3. 有副作用的代碼讓我們的代碼了解起來更加費勁!
  4. 解決副作用的方法有:定義常量、明确 I/O、明确依賴、運用幂等......
  5. 在 js 運用幂等是一個新事物,我們需要逐漸熟悉它。
  6. 沒有副作用的函數就是純函數,純函數是我們追求編寫的!
  7. 将一個不純的函數重構為純函數是首選。但是,如果無法重構,嘗試封裝副作用。(假如一棵樹在森林裡倒下而沒有人在附近聽見,它有沒有發出聲音?—— 有沒有其實已經不重要了,反正聽不到)

繼續閱讀