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.
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ố ssn
và phone
ở 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}$/
và /^\(\d{3}\)\d{3}-\d{4}$/
được thay thế bằng biến regex
.
Và phần sau của message gồm SNS
và Phone 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 parseAddress
và parseFullName
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 address
và name
, type
cho Address
và Name
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ỉ? :D
Để ý 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 parsePhone
và validateValueWithFunc
.
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 parseSsn
và parsePhone
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
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