Trở thành Functional Programmer - Phần 2

Đây là bài dịch từ bài gốc ở link sau : https://medium.com/@cscalfani/so-you-want-to-be-a-functional-programmer-part-2-7005682cec4a#.eqo0af4ak

Những bước đầu tiên của việc hiểu rõ các concepts trong lập trình hàm (Functional Programming - FP) là những bước quan trọng nhất, và đôi khi là những bước khó khăn nhất. Nhưng với cách tiếp cận đúng đắn, mọi thứ sẽ trở nên dễ hiểu hơn rất nhiều. Và đây là series được tạo ra nhằm mục đích giúp các bạn dễ thở hơn trong quá trình tiếp cận với FP.

Link của phần trước: Phần 1

Một chút lưu ý

Tôi mong các bạn sẽ đọc các dòng code một cách từ tốn. Và hãy đảm bảo rằng bạn đã hoàn toàn hiểu rõ, nắm vững các nội dung vừa đọc được trước khi tiếp tục. Các phần tiếp theo được phát triển từ các phần trước đó, nên nếu bạn vội vã, bạn có thể bỏ qua một vài kiến thức quan trọng, cần thiết cho các phần sau này.

Tái cấu trúc - Refactoring

Phần này sẽ nói về Tái cấu trúc - một kỹ thuật khá quen thuộc đối với các lập trình viên. Sau đây mời các bạn xem một đoạn code Javascript:

function validateSsn(ssn) { if (/^\d{3}-\d{2}-\d{4}**/.exec(**ssn**))         console.log('Valid **SSN**');     else         console.log('Invalid **SSN**'); } function validatePhone(**phone**) {     if (/**^\(\d{3}\)\d{3}-\d{4}/.exec(phone))         console.log('Valid Phone Number');     else         console.log('Invalid Phone Number'); }

Hẳn là bạn đã từng viết những dòng code kiểu như thế này, và theo thời gian, bạn sẽ dần nhận ra rằng 2 hàm phía trên khá là giống nhau, chỉ có đôi chút khác biệt (phần được bôi đậm).

Vì thế, thay vì copy lại hàm validateSSn và thay đổi để tạo ra hàm validatePhone mới, chúng ta có thể chỉ tạo một hàm và biến các phần khác nhau thành tham số.

Trong ví dụ này, chúng ta nên tham số hóa phần value, phần regulare expression và phần message được in ra (là những phần bôi đậm ở trên).

Đây là code sau khi refactor:

function validateValue(value, regex, type) {
    if (regex.exec(value))
        console.log('Invalid ' + type);
    else
        console.log('Valid ' + type);
}

Các tham số ssnphone ở trong phần code cũ đã được thay thế bằng biến value.

2 biểu thức chính quy (regulare expression) /^\d{3}-\d{2}-\d{4}$//^\(\d{3}\)\d{3}-\d{4}$/ được thay thế bằng biến regex.

Và phần sau của message gồm SNSPhone Number sẽ được thay thế bằng biến type.

Việc chỉ có một hàm như thế này sẽ tốt hơn rất nhiều so với việc có 2, hoặc xấu hơn là 3, 4 hay 10 hàm. Việc này sẽ giúp code của bạn sạch sẽ và dễ bảo trì hơn.

Ví dụ, nếu có lỗi xảy ra, bạn sẽ chỉ phải fix ở một chỗ thay vì tìm kiếm trong tất cả source code để tìm các chỗ mà hàm này CÓ THỂ đã được copy/paste và thay đổi.

Tiếp theo chúng ta cùng xem xét trường hợp phức tạp hơn một chút :

function validateAddress(address) {
    if (parseAddress(address))
        console.log('Valid Address');
    else
        console.log('Invalid Address');
}
function validateName(name) {
    if (parseFullName(name))
        console.log('Valid Name');
    else
        console.log('Invalid Name');
}

Ở đây parseAddressparseFullName là 2 hàm đều nhận vào một chuỗi và trả về true nếu parse thành công.

Bạn sẽ refactor code trong trường hợp này như thế nào đây?

Giống như trường hợp trước đó, ta có thể sử dụng biến value cho addressname, type cho AddressName giống như đã làm trước đó, nhưng ở vị trí của biểu thức chính quy lúc trước giờ lại là 2 hàm khác nhau.

Nếu như chúng ta có thể đưa hàm vào tham số thì ...

Concept 3: Higher-Order Functions

(Chú thích của người dịch: High-order Function mình đã tìm hiểu nhưng khó có từ tiếng Việt tương đương, bạn có thể hiểu Higher-Order Functions có nghĩa là Hàm có cấp bậc cao hơn - với ý nghĩa là hàm có nhiều khả năng và linh hoạt hơn so với các ngôn ngữ Imperative Programming thông thường như Java, C, C++)

Rất nhiều ngôn ngữ lập trình không hỗ trợ việc đưa hàm vào thành tham số. Một số ngôn ngữ thì có thể nhưng cách thực hiện thì không hề dễ dàng chút nào.

Trong Functional Programming, một hàm sẽ được coi như là một công dân hạng nhất trong ngôn ngữ đó. Hay nói cách khác, hàm sẽ giống như các loại giá trị (số, text, object,...) khác.

Bởi vì hàm sẽ được coi như các loại giá trị khác, nên hàm có thể được truyền dưới dạng tham số.

Mặc dù không phải là ngôn ngữ hỗ trợ FP chính thống, nhưng một vài concept trong FP có thể được thực hiện bởi Javascript. Và đây là cách thu gọn hai hàm ở trên thành một bằng việc đưa hàm thực hiện việc parse dữ liệu thành tham số của hàm mới có tên là parseFunc:

function validateValueWithFunc(value, parseFunc, type) {
    if (parseFunc(value))
        console.log('Invalid ' + type);
    else
        console.log('Valid ' + type);
}

Và hàm mới của chúng ta được gọi là Higher-order Function.

Higher-order Functions là các hàm hoặc nhận hàm làm tham số, hoặc trả về hàm, hoặc vừa nhận hàm làm tham số vừa trả về hàm.

Và giờ chúng ta có thể viết lại cả 4 hàm trên bằng cách sử dụng hàm validateValueWithFunc như sau ( lưu ý rằng hàm Regex.exec sẽ trả về true nếu chuỗi ký tự match với biểu thức chính quy ):

validateValueWithFunc('123-45-6789', /^\d{3}-\d{2}-\d{4}$/.exec, 'SSN');
validateValueWithFunc('(123)456-7890', /^\(\d{3}\)\d{3}-\d{4}$/.exec, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

Code được viết lại như trên nhìn ngon hơn hẳn so với việc có 4 hàm từa tựa nhau nhỉ? 😄

Để ý kĩ hơn một chút, 2 hàm sử dụng biểu thức chính quy nhìn có vẻ khá là rườm rà, nhất là khi sau này biểu thức chính quy có thể trở nên dài và phức tạp hơn. Chúng ta có thể làm cho nó gọn hơn bằng cách đưa phần gọi biểu thức chính quy ra ngoài như sau :

var parseSsn = /^\d{3}-\d{2}-\d{4}$/.exec;
var parsePhone = /^\(\d{3}\)\d{3}-\d{4}$/.exec;

validateValueWithFunc('123-45-6789', parseSsn, 'SSN');
validateValueWithFunc('(123)456-7890', parsePhone, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

Mọi thứ tốt hơn rồi nhỉ. Sau này nếu muốn kiểm tra một số điện thoại khác, thay vì phải copy lại biểu thức chính quy, ta có thể sử dụng hàm parsePhonevalidateValueWithFunc.

Tuy nhiên, hãy thử tưởng tượng nếu chúng ta có nhiều biểu thức chính quy cần thực hiện, ngoài 2 hàm parseSsnparsePhone thì sẽ ra sao nhỉ? Để ý rằng mỗi khi gọi hàm xử lý biểu thức chính quy, ta đều phải gọi thêm .exec vào cuối, và nếu số lượng biểu thức chính quy tăng lên thì sẽ khá là phiền phức, và tin tôi đi, sẽ có lúc bạn sẽ quên mất không thêm .exec vào đó.

Để tránh mắc phải lỗi này, chúng ta có thể tạo ra một hàm dạng high-order function được dùng để trả về hàm exec từ biểu thức chính quy truyền vào như sau :

function makeRegexParser(regex) {
    return regex.exec;
}
var parseSsn = makeRegexParser(/^\d{3}-\d{2}-\d{4}$/);
var parsePhone = makeRegexParser(/^\(\d{3}\)\d{3}-\d{4}$/);
validateValueWithFunc('123-45-6789', parseSsn, 'SSN');
validateValueWithFunc('(123)456-7890', parsePhone, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

Ở đây, hàm makeRegexParser nhận tham số là một biểu thức chính quy, và trả về hàm exec của biểu thức chính quy đó, với tham số là một chuỗi string. Lúc này, hàm validateValueWithFunc sẽ nhận chuỗi string từ biến value, sau đó truyền sang hàm parseSsn hoặc parsePhone, đối ví dụ trên thực chất sẽ là hàm exec được trả về từ hàm parseSsn hoặc parsePhone.

Như các bạn đã thấy, đây tuy chỉ là một quá trình tái cấu trúc code nho nhỏ, nhưng nó đã thể hiện khả năng và sự tiện lợi của High-order function khi hỗ trợ việc trả về hàm.

Lợi ích của việc thay đổi này sẽ thể hiện một cách rõ ràng hơn khi hàm makeRegexParser trở nên phức tạp hơn.

Dưới đây là một ví dụ khác về một High-order function có kết quả trả về là một hàm :

function makeAdder(constantValue) {
    return function adder(value) {
        return constantValue + value;
    };
}

Chúng ta có một hàm makeAdder nhận vào tham số là constantValue (giá trị cố định), và trả về một hàm tên là adder, với khả năng cộng thêm giá trị constantValue vào tham số truyền vào.

Đây là cách hàm adder có thể sử dụng :

var add10 = makeAdder(10);
console.log(add10(20)); // prints 30
console.log(add10(30)); // prints 40
console.log(add10(40)); // prints 50

Chúng ta đã tạo ra một hàm có tên là add10 bằng việc truyền giá trị 10 vào hàm makeAdder, mà hàm add10 sẽ hoạt động đúng như tên của nó, cộng thêm 10 vào bất kỳ biến nào truyueenf vào.

Để ý rằng hàm adder có thể truy cập đến biến constantValue ngay cả khi hàm makeAddr đã hoàn thành. Lý do là bởi vì biến constantValue đã ở trong cùng một scope (phạm vi) khi hàm adder được tạo.

Khả năng này rất quan trọng bởi vì nếu thiếu nó, việc hàm trả về hàm sẽ không còn nhiều lợi ích nữa. Vì thế việc hiểu cách hoạt động và tên gọi của khả năng này cũng là điều mà chúng ta cần tìm hiểu, và nó có tên là Closure.

Concept 4: Closures - Bao đóng

Dưới đây là một ví dụ giả tưởng nhằm minh họa việc hàm sử dụng closures:

function grandParent(g1, g2) {
    var g3 = 3;
    return function parent(p1, p2) {
        var p3 = 33;
        return function child(c1, c2) {
            var c3 = 333;
            return g1 + g2 + g3 + p1 + p2 + p3 + c1 + c2 + c3;
        };
    };
}

Trong ví dụ này, hàm child có thể truy cập các biến của chính nó, các biến của hàm parent và cả các biến của hàm grandParent nữa. (đủ g1, g2, g3, p1, p2, p3, c1, c2, c3)

Hàm parent có thể truy cập các biến của chính nó và của hàm grandParent. (bao gồm g1, g2, g3, p1, p2, p3)

Hàm grandParent chỉ có thể truy cập các biến của chính nó. (bao gồm g1, g2, g3).

(Mọi người có thể tham khảo hình vẽ kim tự tháp phía bên trên để hiểu rõ hơn).

Sau đây là 1 ví dụ sử dụng hàm 3 đời ở trên :

var parentFunc = grandParent(1, 2); // returns parent() - trả về hàm parent() với g1 = 1, g2 = 2, g3 = 3
var childFunc = parentFunc(11, 22); // returns child() - trả về hàm child() với g1 = 1, g2 = 2, g3 = 3, p1 = 11, p2 = 22, p3 = 33
console.log(childFunc(111, 222)); // prints 738 - in ra 738 vì :
// 1 + 2 + 3 + 11 + 22 + 33 + 111 + 222 + 333 == 738

Ở đây, biến parentFunc sẽ giữ cho scope của hàm parent tồn tại sau khi thực hiện hàm grandParent, lúc này hàm parent sẽ trả về và tham chiếu thông qua biến parentFunc (scope của parent ở đây sẽ bao gồm các giá trị được hàm parent tham chiếu đến, tức là sẽ lưu giữ các giá trị g1, g2, g3, p1, p2, p3).

Tương tự như vậy, biến childFunc sẽ giữ scope của hàm child tồn tại sau khi thực hiện gọi hàm parent thông qua biến parentFunc, lúc này hàm child được trả về và tồn tại vì có biến childFunc tham chiếu đến.

Mỗi khi một hàm được tạo ra, tất cả các giá trị nằm trong scope của nó ở thời điểm hàm được tạo sẽ được lưu trữ và có thể truy cập trong suốt vòng đời của hàm đó. Và hàm sẽ còn tồn tại chừng nào còn có tham chiếu (reference) đến nó. Ví dụ, scope của hàm child sẽ còn tồn tại cho đến khi nào biến childFunc vẫn tham chiếu đến nó.

Một closure (bao đóng) là một scope của một hàm mà sẽ tồn tại chừng nào còn có tham chiếu đến hàm đó.

Lưu ý rằng, trong Javascript, closures sẽ gây ra nhiều rắc rối bởi vì các biến có thể thay đổi (mutable), và vì thế các biến đó có thể bị/được thay đổi giá trị từ lúc chúng được đóng lại cho đến lúc hàm trả về được gọi.

(Ở đây đóng lại ý nói lúc dùng High-order function để trả về một hàm, lúc này các biến trong scope của hàm đó vẫn có thể truy cập và thay đổi giá trị, do đó đến lúc thực thi hàm này, các biến này giá trị có thể khác so với lúc được trả về, gây ra các kết quả không mong muốn)

Thật may mắn là các biến trong FP sẽ là bất biến (immutable - đã nói đến ở phần 1), nên các lỗi có thể xảy ra do Closure như trong JS sẽ không gặp phải nữa.

Đầu của tôi!!!!

Hôm nay đến đây thôi là đủ.

Trong các phần sau của bài viết này, tôi sẽ nói về các vấn đề như là Functional Composition, Currying, các functional functions cơ bản (như là map, filter, fold,... ), và một vài thứ nữa

Phần tiếp theo mời các bạn xem ở đây : Part 3

Nếu bạn muốn tham gia vào cộng đồng các nhà phát triển web muốn học và giúp đỡ lẫn nhau về FP trong Elm, mời các bạn tham gia Group Facebook sau: Learn Elm Programming

Và đây là Twitter của tác giả : @cscalfani

All Rights Reserved