12/08/2018, 16:44

Functional Programming in JavaScript - Functions

Functional Programming (FP) không chỉ đơn thuần là việc lập trình sử dụng từ khóa function . Tuy nhiên, function chiếm vị trí trung tâm trong FP, cách chúng ta sử dụng function sẽ làm cho code của chúng ta trở nên functional hay không. Tuy nhiên function là gì, ý nghĩa và cách sử dụng của ...

Functional Programming (FP) không chỉ đơn thuần là việc lập trình sử dụng từ khóa function. Tuy nhiên, function chiếm vị trí trung tâm trong FP, cách chúng ta sử dụng function sẽ làm cho code của chúng ta trở nên functional hay không.

Tuy nhiên function là gì, ý nghĩa và cách sử dụng của chúng ra sao?

Trong bài viết này chúng ta sẽ tìm hiểu các kiến thức cơ bản về function (trong JavaScript). Bài viết này không chỉ dành cho các FP programmer, các lập trình viên khác khi làm việc với JavaScript cũng nên có một cái nhìn chi tiết về function. Trước khi đi sâu vào tìm hiểu các khía cạnh của FP, việc nắm rõ về function là khá cần thiết.

Function là gì?

Khi mới làm quen với lập trình, một câu trả lời khá đơn giản cho câu hỏi: "Function là gì?" đó là "Function là một tập hợp các logic được nhóm lại nhằm mục đích thực thi một hay nhiều lần". Việc định nghĩa hàm như vậy không sai tuy nhiên nó chưa đưa ra được bản chất quan trọng của hàm từ đó được áp dụng vào FP.

Trong toán học, một hàm luôn nhận vào các input(s) và trả về một output. Khi đọc các tài liệu về FP, một khái niệm thường được nhắc đến là morphism. Hiểu đơn giản nó mô tả một tập các giá trị được chuyển đổi thành một tập các giá trị khác. Trong một hàm, morphism biểu diễn mối quan hệ giữa input(s) và output(s) của hàm đó.

Hàm (Function) và Thủ tục (Procedure)

FP bao hàm việc sử dụng các hàm như khi chúng được sử dụng trong toán học.

Chúng ta thường quen với suy nghĩ hàm (function) và thủ tục (procedure) là như nhau. Vậy sự khác nhau giữa chúng là gì?

  • Một procedure (thủ tục) là một tập hợp ngẫu nhiên các chức năng, nó có thể nhận vào các inputs hoặc không. Một thủ tục có thể trả về một giá trị (return value) hoặc không.
  • Một function (hàm) sẽ nhận vào các inputs và luôn trả về một giá trị nào đó.

Khi lập trình sử dụng FP, chúng ta nên sử dụng hàm nhiều nhất có thể và hạn chế việc sử dụng các thủ tục. Tất các hàm nên nhận vào các inputs và trả về các outputs nhất định.

Function Input

Trong phần trước chúng ta biết rằng hàm sẽ nhận vào các inputs. Trong phần này của bài viết chúng ta sẽ tìm hiểu rõ hơn về điều đó (trong JavaScript nói riêng).

Thi thoảng bạn có thể nghe đến inputs của hàm dưới hai cái tên: arguments (đối số) và parameters (tham số). Vậy chúng có khác gì nhau?

Arguments là các giá trị mà chúng ta truyền cho hàm. Parameters là tên của các biến được định nghĩa trong hàm (hay function signature - chữ ký hàm), giá trị chúng ta truyền vào - arguments sẽ được gán cho các biến đó.

function foo(x, y) {
    //
}

var a = 3;

foo(a, a * 2);

a và a * 2 là các arguments của hàm foo() khi nó được thực thi. x và y là các parameter sẽ được gán cho các arguments mà hàm nhận về.

Trong JavaScript, không có quy ước rằng số lượng argumentsparameters phải giống nhau. Nếu số lượng arguments truyền vào nhiều hơn số lượng parameters được định nghĩa, các arguments dư thừa sẽ không làm ảnh hưởng đến kết quả của hàm. Các arguments đó có thể được truy cập bằng một vài cách khác nhau, trong đó cách quen thuộc nhất là sử dụng đối tượng arguments. Trường hợp ngược lại khi số lượng arguments ít hơn số lượng parameters được định nghĩa, các parameter không có argument tương ứng sẽ nhận giá trị là undefined.

Defaulting Parameters

Trong ES6, các parameters có thể nhận các giá trị mặc định - default values. Trong trường hợp argument tương ứng của một parameter (có giá trị default) không được truyền vào (hoặc giá trị truyền vào là undefined), phép gán trong định nghĩa hàm sẽ được thực thi và giá trị mặc định sẽ được sử dụng.

function foo(x = 3) {
    console.log(x);
}

foo();                      // 3
foo(undefined);             // 3
foo(nul);                   // null
foo(0);                     // 0

Việc định nghĩa các giá trị mặc định cho các parameters sẽ làm tăng tính khả dụng của hàm. Tuy nhiên, việc sử dụng default parameter có thể làm tăng sự phức tạp khi đọc và hiểu các biến thể khi thực thi một hàm. Chúng ta nên cân bằng giữa hai yếu tố đó khi sử dụng default parameters.

Counting Inputs

Số lượng arguments mà một hàm cần, số lượng arguments cần thiết phải truyền cho hàm được xác định bởi số lượng parameters được định nghĩa.

function foo(x,y,z) {
    //
}

Trong ví dụ trên, hàm foo() cần nhận vào 3 arguments do có 3 parameters đã được định nghĩa. Số lượng parameters của một hàm thường được gọi là arity. Trong ví dụ trên arity của hàm foo() là 3.

Hơn nữa, hàm với arity là 1 thường được gọi là unary function, với arity là 2 ta có binary function, với arity từ 3 trở nên ta sẽ có n-ary function.

Trong nhiều trường hợp, chúng ta cần xác định arity của một hàm thông qua tham chiếu (reference) của nó trong khi chương trình được thực thi. Việc này có thể được thực hiện sử dụng length property của tham chiếu hàm (function reference).

function foo(x, y, z) {
    //
}

foo.length;             // 3

Lý do chúng ta cần xác định arity của một hàm trong quá trình thực thi là khi logic của chúng ta sử dụng tham chiếu đến một hàm nào đó từ nhiều nguồn khác nhau. Chúng ta cần truyền các argument khác nhau cho mỗi tham chiếu đó dựa trên số arity.

Trong ví dụ bên dưới, tham chiếu hàm fn có thể nhận vào một, hai hoặc ba arguments. Tuy nhiên chúng ta muốn biến x sẽ được truyền vào vị trí của argument cuối cùng:

if (fn.length == 1) {
    fn(x);
}
else if (fn.length == 2) {
    fn(undefined, x);
}
else if (fn.length == 3) {
    fn(undefined, undefined, x);
}

Property length của một hàm là read-only và được xác định tại thời điểm hàm được khai báo. Có thể hiểu property này như một thông tin metadata của hàm nhằm mô tả cách sử dụng của hàm đó.

Cần lưu ý rằng việc khai báo các parameters khi định nghĩa hàm sẽ làm thay đổi giá trị của property length:

function foo(x, y = 2) {
    //
}

function bar(x, ...args) {
    //
}

function baz({a, b}) {
    //
}

foo.length;             // 1
bar.length;             // 1
baz.length;             // 1

Property length cho phép chúng ta xác định số lượng parameters cần thiết để thực thi một hàm. Tuy nhiên trong nhiều trường hợp chúng ta cũng cần xác định số lượng arguments mà hàm nhận được khi nó được thực thi. Mỗi hàm sẽ có một đối tượng arguments (dạng giống như một mảng) chưa các tham chiếu đến các arguments được truyền cho hàm đó. Chúng ta có thể sử dụng length property của arguments object để xác định chính xác số lượng arguments đã được truyền vào cho hàm.

function foo(x, y, z) {
    console.log(arguments.length);
}

foo(3, 4);    // 2

Trong ES5 (strict mode), arguments object được xem là đã deprecated và các lập trình viên nên tránh sử dụng object đó khi có thể. Trong JavaScript, việc giữ backward compatibility là rất quan trọng, dó đó arguments object sẽ không bao giờ bị loại bỏ hoàn toàn. Tuy nhiên có nhiều lời khuyên cho rằng chúng ta không nên sử dụng nó thường xuyên. Tuy nhiên tại thời điểm hiện tại, việc sử dụng arguments.length là hoàn toàn khả thi và không mang lại những ảnh hưởng xấu cho chương trình. Chú ý rằng chúng ta không nên sử dụng arguments object để truy cập đến các arguments được truyền vào cho hàm như arguments[1], chỉ nên sử dụng arguments.length khi cần thiết mà thôi.

Vậy có cách nào để truy cập đến các arguments được truyền vào ngoài những arguments tương ứng với các parameters đã được khai báo?

Một hàm nhận vào một số lượng arguments không xác định được gọi là variadic function. Đối với FP, việc sử dụng loại hàm này nên được hạn chế đến mức thấp nhất có thể.

Giả sử chúng ta muốn truy cập đến các arguments giống như một mảng thông thường (thường là các arguments không có parameters tương ứng. Chúng ta sẽ thực hiện việc đó như thế nào?

Trong phiên bản ES6, chúng ta có thể định nghĩa hàm với ... operator hay spread / rest operator.

function foo(x, y, z, ...args) {
    //
}

...args trong danh sách các parameters của hàm trong ví dụ trên, cho phép chúng ta thu thập các arguments (nếu có) không được gán cho các parameters và đặt chúng vào trong một mảng có tên là args (nếu không có arguments nào args sẽ là một mảng rỗng). Để ý rằng args sẽ không chứa các giá trị tương ứng với các parameters x, y và z.

function foo(x, y, z, ...args) {
    console.log(x, y, z, args);
}

foo();                // undefined undefined undefined []
foo(1, 2, 3);         // 1 2 3 []
foo(1, 2, 3, 4);      // 1 2 3 [4]
foo(1, 2, 3, 4, 5);   // 1 2 3 [4, 5]

Chú ý rằng trong ví dụ trên 4 sẽ ở vị trí có index là 0 trong mảng args thay vì index là 3. Giá trị của property length sẽ không bao hàm các giá trị 1, 2, and 3.

Chúng ta có thể sử dụng ... operator trong danh sách các parameter mà không cần định nghĩa các named parameters

function foo(...args) {
    //
}

Mảng args bây giờ sẽ chứa tất cả các arguments được truyền vào cho hàm và chúng ta có thể sử dụng arguments.length để biết chính xác số lượng arguments đó. Hơn nữa chúng ta có thể sử dụng các cú pháp liên quan đến mảng để xử lý các arguments đó.

Arrays of Arguments

Giả sử chúng ta muốn truyền một mảng các giá trị như tham số cho một hàm? Chúng ta sẽ thực hiện việc đó như thế nào?

function foo(...args) {
    console.log(args[3]);
}

var arr = [1, 2, 3, 4, 5];

foo(...arr);                      // 4

Chúng ta có thể sử dụng ... operator tuy nhiên không chỉ trong danh sách các tham số mà còn trong cả danh sách các đối số khi hàm được thực thi. Trong hai trường hợp đó ... operator có ý nghĩa khác nhau. Trong danh sách các tham số, ... operator có ý nghĩa thu thập các arguments thành một mảng. Trong danh sách các đối số, ... operator được sử dụng để tách biệt các arguments thành những giá trị riêng biệt trước khi truyền cho hàm foo()

Chúng ta có thể sử dụng spread operator nhiều lần khi truyền arguments cho

var arr = [2];

foo(1, ...arr, 3, ...[4, 5]);      // 4

Việc sử dụng ... operator khiến cho việc sử dụng mảng các arguments trở nên dễ dàng hơn.

Parameter Destructuring

Tiếp tục với ví dụ về hàm foo() ở trên:

function foo(...args) {
    //
}

foo(...[1, 2, 3]);

Giá sử bây giờ chúng ta muốn truyền một mảng các arguments thay vì từng argument riêng biệt khi thực thi hàm foo(), chúng ta chỉ cần bỏ spread operator đi như sau:

function foo(args) {
    //
}

foo([1, 2, 3]);

Tuy nhiên nếu chúng ta muốn gán named parameter cho hai giá trị đầu của mảng được truyền vào. Trong ES6, chúng ta có thể sử dụng destructuring để thực hiện việc đó. Destructuring có thể được sử dụng với mảng hoặc đối tượng nhằm phân tách các thành phần bên trong đó.

function foo([x, y, ...args] = []) {
    //
}

foo([1,2,3]);

Trong ví dụ trên việc sử dụng [...] xung quanh danh sách các parameter được gọi là array parameter destructuring. Ở đây giá trị đầu tiên của arguments (dạng mảng) sẽ được gán cho biến x, giá trị thứ hai sẽ được gán cho biến y và các giá trị còn lại sẽ được tập trung trong mảng args.

Declarative vs. Imperative

Trong ví dụ trên chúng ta hoàn toàn có thể thực hiện việc gán giá trị cho biến x và y cũng như mảng args như sau:

function foo(params) {
    var x = params[0];
    var y = params[1];
    var args = params.slice(2);
}

Trên thực tế declarative code thường dễ hiểu hơn imperative code.

Declarative code tập trung vào kết quả đầu ra của một đoạn code (như việc sử dụng ... operator trong phần trước).

Imperative code tập trung nhiều hơn vào việc làm thế nào để có kết quả như mong muốn.

Việc định nghĩa hàm foo() như trong phần trước giúp cho hàm dễ đọc hơn do việc destructuring đã ẩn đi quá trính gán các giá trị cho các parameters. Chúng ta sẽ chỉ cần quan tâm đến việc làm gì với những parameters đó mà thôi.

Named Arguments

Ngoài việc sử dụng destructuring với mảng các parameters chúng ta cũng có thể sử dụng kĩ thuật đó với object parameters:

function foo({x, y} = {}) {
    console.log(x, y);
}

foo({
    y: 3
});                    // undefined 3

Trong ví dụ trên, chúng ta truyền một đối tượng như một argument duy nhất cho hàm, đối tượng đó sẽ được tách biệt thành hai biến x và y. Hai biến này sẽ được gán giá trị bằng các property có tên tương ứng trong object được truyền vào. Ở đây object truyền vào không có property nào với tên là x do đó undefined sẽ được gán cho biến x bên trong hàm foo()

Nếu chúng ta sử dụng foo(undefined,3), vị trí sẽ được sử dụng để gán argument cho parameter, ở đây 3 ở vị trí thứ 2 nên sẽ được gán cho y parameter. Tuy nhiên khi sử dụng object parameter destructuring, property của object sẽ được sử dụng để xác định vị trí của các arguments. Thay vì phải truyền vào undefined khi chúng ta không quan tâm đến giá trị của x chúng ta chỉ cần bỏ property với tên x trong object trước khi truyền cho hàm.

Function Output

Trong JavaScript, hàm luôn trả về một giá trị. Trong ví dụ bên dưới, cả 3 hàm đều trả cùng một giá trị mặc dù cách viết khác nhau:

function foo() {}

function bar() {
    return;
}

function baz() {
    return undefined;
}

undefined sẽ được trả về khi hàm không có return statement hoặc là một empty return statement.

Tuy nhiên khi sử dụng FP, chúng ta nên sử dụng hàm thay vì thủ tục, do đó hàm của chúng ta nên có giá trị trả về và thường không phải là undefined.

return statement chỉ nhận vào một giá trị duy nhất. Do vậy khi hàm cần trả về nhiều giá trị thì cách duy nhất là gộp các giá trị đó vào bên trong một mảng hay một đối tượng.

function foo() {
    var retValue1 = 11;
    var retValue2 = 31;
    return [retValue1, retValue2];
}
var [x, y] = foo();
console.log(x + y);           // 42

Gộp nhiều giá trị thành một mảng (hoặc một đối tượng) và trả về, sau đó destructuring các giá trị đó thành các phép gán riêng biệt là một các để thực hiện việc trả về nhiều outputs cho một hàm.

Early Returns

Trong một hàm return statement không chỉ có tác dụng trả về giá trị. Nó còn được sử dụng như một control structure do return statement sẽ dừng quá trình thực thi của hàm tại điểm đó. Một hàm với nhiều return statements có thể trả về nhiều giá trị khác nhau và đôi khi nó sẽ gây khó khăn trong việc đọc và hiểu output của hàm dó có nhiều khả năng có thể xảy ra.

function foo(x) {
    if (x > 10) return x + 1;

    var y = x / 2;

    if (y > 3) {
        if (x % 2 == 0) return x;
    }

    if (y > 1) return y;

    return x;
}

Trong ví dụ trên, return statement không chỉ được sử dụng để trả về các giá trị khác nhau mà còn được dùng để điều khiển việc thực thi hàm. Chúng ta sẽ có nhiều cách tốt hơn để điều khiển việc thực thi hàm (ví dụ như sử dụng if statement), xét ví dụ dưới đây:

function foo(x) {
    var retValue;

    if (retValue == undefined && x > 10) {
        retValue = x + 1;
    }

    var y = x / 2;

    if (y > 3) {
        if (retValue == undefined && x % 2 == 0) {
            retValue = x;
        }
    }

    if (retValue == undefined && y > 1) {
        retValue = y;
    }

    if (retValue == undefined) {
        retValue = x;
    }

    return retValue;
}

Thoạt nhìn chúng ta có thể thấy phiên bản này của hàm foo() khá dài dòng. Tuy nhiên logic bên trong sẽ dễ dàng cho việc đọc hiểu hơn. Trong mỗi nhánh retValue sẽ được kiểm tra xem đã có giá trị được gán chưa trước khi thay đổi.

Thay vì sử dụng return statement như một cách để dừng việc thực thi một hàm, chúng ta sử dụng if statement để xác định phép gán cho biến retValue và chỉ trả về giá trị của retValue ở cuối hàm.

Tuy nhiên không phải lúc nào chúng ta cũng chỉ sử dụng một return statement duy nhất cho hàm hoặc không bao giờ sử dụng statement đó như một các để kết thúc sớm quá trình thực thi của hàm. Điều quan trọng là chúng ta cần diễn giải các logic bên trong hàm một các trực tiếp nhất có thể, và việc lạm dụng return statement sẽ làm cho logic trở nên trừu tượng hơn.

Unreturned Outputs

Một kĩ thuật được sử dụng khá nhiều khi viết logic cho một hàm đó là một vài hoặc tất cả các giá trị trả về của hàm đó được thực hiện bằng cách thay đổi các biến bên ngoài scope của hàm.

Giả sử ta có hàm sau: f(x)=2x2+3f(x) = 2x^2 + 3f(x)=2x2+3 và đoạn logic sau:

var y;

function foo(x) {
    y = (2 * Math.pow(x,, 2)) + 3;
}

foo(2);

y;                      // 11

Ví dụ này khá dị vì bình thường chúng ta chỉ cần trả về giá trị của y ngay bên trong hàm thay vì thay đổi giá trị của một biến y bên ngoài hàm:

function foo(x) {
    return (2 * Math.pow(x, 2)) + 3;
}

var y = foo(2);

y;                      // 11

Cả hai hàm trên đều thực hiện cùng một công việc. Vậy có lí do gì chúng ta nên sử dụng một cách hơn cách còn lại? Yes!

Sự khác biệt giữa hai cách viết hàm foo() ở đây là: ở cách viết thứ hai output được trả về một cách trực tiếp (explicit), còn trong ví dụ đầu tiên output được trả về một cách gián tiếp (thông qua phép gán cho biến y). Thông thường việc trả về giá trị một cách trực tiếp sẽ tốt hơn là gián tiếp.

Thay đổi giá trị của một biến bên ngoài scope của hàm (outer scope) như phép gán biến y trong hàm foo() ở trên là một cách để thực hiện việc trả về output một cách gián tiếp. Một ví dụ phức tạp hơn là thay đổi giá trị của một biến không phải cục bộ thông qua tham chiếu.

Xét ví dụ sau:

function sum(list) {
    var total = 0;
    for (let i = 0; i < list.length; i++) {
        if (!list[i]) list[i] = 0;

        total = total + list[i];
    }

    return total;
}

var nums = [1, 3, 9, 27, , 84];

sum(nums);            // 124

Giá trị trả về của hàm với mảng nums là argument là 124 - là giá trị chúng ta trả về trực tiếp trong hàm - total. Tuy nhiên nếu chúng ta so sánh mảng nums trước và sau khi gọi hàm sum, chúng ta sẽ thấy có sự khác biệt.

Giá trị của phần tử ở vị trí index 4 sẽ chuyển từ undefined sang 0. Phép gán đơn giản list[i] = 0 bên trong hàm đã làm thay đổi giá trị của mảng nums ở scope bên ngoài cho dù phép gán đó được thực hiện trên mảng tham số list.

Lý do thì chắc mọi người cũng đã hiểu rõ. list ở đây giữ một bản copy tham chiếu của mảng nums chứ không phải copy các giá trị của mảng đó. JavaScript sử dụng tham chiếu và copy của tham chiếu cho mảng (arrays), đối tượng (objects) và hàm (functions). Vì vậy chúng ta cần chú ý khi làm việc với các kiểu dữ liệu này nếu không muốn thay đổi giá trị của chúng một cách không có chủ định.

Các hàm có thể làm thay đổi giá trị ngoài scope một cách gián tiếp thì được gọi trong FP với tên là side effects. Ngược lại các hàm không có side effects sẽ được gọi là pure function. Trên thực tế chúng ta sẽ cố gắng sử dụng các pure function nhiều nhất có thể và tránh các side effects.

Functions of Functions

Functions có thể nhận và trả ra các giá trị thuộc bất kỳ kiểu nào. Một hàm nhận hoặc trả về một hoặc nhiều hàm khác được gọi là higher-order function.

function forEach(list,fn) {
    for (let v of list) {
        fn(v);
    }
}

forEach([1,2,3,4,5], function each(val) {
    console.log(val);
});
// 1 2 3 4 5

forEach(..) là một higher-order function do nó nhận vào argument là một hàm khác.

Một higher-order function có có thể trả về một hàm khác như trong ví dụ sau:

function foo() {
    return function inner(msg){
        return msg.toUpperCase();
    };
}

var f = foo();

f("Hello!");          // HELLO!

Sử dụng return không phải là cách duy nhất để trả về một hàm bên trong một hàm khác:

function foo() {
    return bar(function inner (msg) {
        return msg.toUpperCase();
    });
}

function bar (func) {
    return func("Hello!");
}

foo();                  // HELLO!

Các hàm sử dụng các hàm khác như những giá trị thông thường là higher-order functions như định nghĩa. Trong FP, high-order functions được sử dụng khá phổ biến.

Keeping Scope

Một trong những điều khá mạnh mẽ trong lập trình nói chung và trong FP nói riêng là các hàm sẽ hoạt động như thế nào khi nó ở trong scope của một hàm khác. Khi một hàm bên trong (inner function) tạo một tham chiếu đến một biến ở hàm bên ngoài (outer function), ta gọi đó là closure (hay bao đóng).

Closure (bao đóng) là khi một hàm nhớ và truy cập đến các biến bên ngoài scope của chính nó, cho dù khi hàm đó được thực thi trong một scope hoàn toàn khác.

function foo(msg) {
    var fn = function inner() {
        return msg.toUpperCase();
    };

    return fn;
}

var helloFn = foo("Hello!");

helloFn();              // HELLO!

Biến tham số msg nằm trong scope của hàm foo() được tham chiếu đến bên trong inner function - fn. Khi hàm foo() được thực thi và inner function được tạo ra, nó nắm bắt quyền truy cập đến biến msg và giữ lại quyền truy cập đó ngay cả sau khi nó được trả về.

Một khi chúng ta có helloFn (một tham chiếu đến inner function trong hàm foo()), foo() đã hoàn thành công việc và scope của nó sẽ bị loại bỏ, đồng nghĩa với việc biến msg sẽ không còn tồn tại nữa. Tuy nhiên trong trường hợp này, điều đó không còn đúng. Lý do là inner function chứa một closure có tham chiếu đến biến msg, do đó msg sẽ được giữ lại thay vì loại bỏ hoàn toàn. Closure với biến msg sẽ tồn tại cho đến khi inner function (tham chiếu bởi helloFn) không còn tồn tại nữa.

Xét thêm một vài ví dụ về closure:

function person(name) {
    return function identify() {
        console.log(`I am ${name}`);
    };
}

var fred = person("Fred");
var susan = person("Susan");

fred();                 // I am Fred
susan();                // I am Susan

Inner function identify() chứa một closure với tham số name.

Việc truy cập thông qua closure không chỉ giới hạn đơn thuần ở việc đọc giá trị gốc của biến name. Chúng ta có thể cập nhật giá trị của biến đó, trạng thái mới của biến sẽ được nhớ đến lần truy cập tiếp theo.

function runningCounter(start) {
    var val = start;

    return function current(increment = 1){
        val = val + increment;
        return val;
              
0