Function là gì? Một function mà nhận một function khác như tham số hoặc trả về một function là một higher-order function. Điều này nghe có vẻ hơi điên rồ nếu như trước đây bạn đã từng làm việc với lập trình hướng đối tượng nhưng hãy cũng tìm hiểu xem thực tế nó hoạt động như thế nào.
Tính tương thích thường đòi hỏi khả năng sử dụng lại ở nhiều nơi trong code của chúng ta. Đây thường được coi là một trong những lợi thế lớn nhất của lập trình hướng đối tượng. Điều này chắc chắn đúng đối với các hàm thuần tuý ví dụ như các hàm mà không bị ảnh hưởng bởi các tác nhân bên ngoài (no side-effects) và có tham chiếu rõ ràng.
Cách thông thường là chúng ta tạo một hàm mới bằng cách gọi các hàm có sẵn khi thực thi. Tuy nhiên, có nhiều cách khác giúp chúng ta sử dụng lại các hàm có sẵn: Trong bài viết ngày chúng ta sẽ trình bày về một số cơ bản của lập trình hàm (functional programming). Bạn sẽ học làm thế nào để đảm bảo chất lượng code theo nguyên tắc DRY bằng cách tận dụng higher-order function mục đích để sử dụng lại các hàm có sẵn trong bối cảnh hoàn toàn mới.
Higher-order function
Higher-order function là đối lập với với first-order function, có thể có một trong 3 hình thức sau:
- Có một hoặc nhiều tham số là một hàm và nó trả về các giá trị
- Nó trả về một hàm, nhưng không có tham số nào là một hàm
- Bao gồm 2 trường hợp ở trên: có một hoặc nhiều tham số là một hàm, nó trả về một hàm
Như bạn đã biết trong Scala, chúng ta đã được nhìn thấy rất nhiều cách sử dụng các higher-order function: Chúng ta gọi các phương thức như là map
, flatMap
hay filter
và truyền vào một hàm mà thường được sử dụng để biến đổi hoặc lọc một collection trong một vài cách. Chúng ta thường xuyên truyền các hàm cho các phương thức này là các anonymous function (Hàm ẩn), đôi khi nó gây trùng lặp code một chút.
Trong bài viết này chúng ta chỉ tập trung vào 2 kiểu của higher-order function có thể làm: Đầu tiên chúng cho phép tạo các hàm mới dựa trên các dữ liệu đầu vào, trong khi kiểu khác cung cấp cho chúng ta tính linh hoạt và hữu ích khi tạo các hàm mới dựa trên các hàm có sẵn. Trong cả 2 cách đều giúp chúng ta tránh việc trùng lặp code.
Hàm được sinh ra từ bất kỳ đâu
Bạn có thể nghĩ rằng khả năng tạo ra một hàm dựa trên dữ liệu đầu vào là không thực sự hữu ích. Mặc dù chúng ta chủ yếu muốn giải quyết cách tạo các hàm mới dựa trên các hàm có sẵn, trước tiên hãy cùng xem cách sử dụng một hàm để tạo ra một hàm mới.
Giả sử chúng ta đang xây dựng một dịch vụ mail miễn phí, nơi người dùng có thể cấu hình một email với giả thiết là bị chặn. Chúng ta biểu diễn các email như là instance của một case class đơn giản như sau:
case class Email( subject: String, text: String, sender: String, recipient: String)
Chúng ta mong muốn có thể lọc các email mới theo tiêu chí được định nghĩa bởi người dùng, nên chúng ta có một hàm lọc dữ liệu dùng để kiểm tra, một hàm với kiểu Email => Boolean
giúp nhận định các email liệu có bị chặn hay không. Nếu điều kiện kiểm tra là true, thì email được chấp nhận, ngược lài email sẽ bị chặn.
type EmailFilter = Email => Boolean def newMailsForUser(mails: Seq[Email], f: EmailFilter) = mails.filter(f)
Lưu ý rằng khi chúng ta đang sử dụng kiểu ẩn danh cho hàm của chúng ta, nó giúp cho chúng ta có thể làm việc với tên đặt có ý nghĩa hơn trong code
Bây giờ, để cho phép người dùng cấu hình cách thức lọc email của họ, chúng ta có thể thực thi một số factory function mà giúp tạo ra các hàm EmailFilter
được cấu hình theo ý thích của người dùng
val sentByOneOf: Set[String] => EmailFilter = senders => email => senders.contains(email.sender) val notSentByAnyOf: Set[String] => EmailFilter = senders => email => !senders.contains(email.sender) val minimumSize: Int => EmailFilter = n => email => email.text.size >= n val maximumSize: Int => EmailFilter = n => email => email.text.size <= n
Cả 4 biến val
ở trên đều là một hàm trả về EmailFilter
, 2 biến đầu tiên thì nhận đầu vào một Set[String]
đại diện cho senders
, 2 biến còn lại thì nhận đầu vào là một Int
đại diện cho độ dài của thông tin mail.
Chúng ta có thể sử dụng bất kỳ hàm nào trong số này để tạo một EmailFilter
mới mà chúng ta có thể đưa nó vào hàm newMailsForUser
val emailFilter: EmailFilter = notSentByAnyOf(Set("johndoe@example.com")) val mails = Email( subject = "It's me again, your stalker friend!", text = "Hello my friend! How are you?", sender = "johndoe@example.com", recipient = "me@example.com") :: Nil newMailsForUser(mails, emailFilter) // returns an empty list
Hàm lọc này sẽ bỏ đi một email trong danh sách bởi vì người dùng quyết định đặt sender trong danh sách đen của họ. Chúng ta có thể sử dụng các factory function để tạo bất kỳ hàm EmailFilter
nào, dựa trên các yêu cầu của người dùng.
Sử dụng lại các hàm có sẵn
Có 2 vấn đề với giải pháp hiện tại. Đầu tiên, có khá nhiều sự trùng lặp trong hàm dùng để kiểm tra ở trên. Lúc đầu chúng ta có đề cập rằng khi xây dựng một hàm, chúng ta phải làm sao cho nó dễ dàng tuân theo quy tắc DRY. Do đó hãy loại bỏ những trùng lặp này.
Để làm điều này với minimumSize
và maximumSize
, chúng ta sẽ đưa vào hàm sizeConstraint
mà dùng một điều kiện để kiểm tra kích thước của email được chấp nhận hay không. Kích thước đó được truyền vào để kiểm tra dùng hàm sizeConstraint
type SizeChecker = Int => Boolean val sizeConstraint: SizeChecker => EmailFilter = f => email => f(email.text.size)
Nào bây giờ chúng ta có thể biểu diễn minimumSize
và maximumSize
theo sizeConstraint
val minimumSize: Int => EmailFilter = n => sizeConstraint(_ >= n) val maximumSize: Int => EmailFilter = n => sizeConstraint(_ <= n)
Hợp nhất các hàm
Đối với 2 hàm còn lại, sentByOneOf
và notSentByAnyOf
, chúng ta sẽ đưa vào một higher-order function chung chung mà cho phép chúng ta biểu diễn một trong hai hàm theo cách khác
Chúng ta hãy tạo một function complement
có điều kiện xác nhận A => Boolean
và trả về một hàm mới mà luôn trả về kết quả ngược lại với điều kiện xác nhận.
def complement[A](predicate: A => Boolean) = (a: A) => !predicate(a)
Bây giờ, đối với một điều kiện xác nhận p
hiện tại, chúng ta có thể nhận được kết quả bổ sung bằng cách gọi hàm complement
. Tuy nhiên, sentByAnyOf
không phải là hàm kiểm tra true
hay false
mà nó trả về một EmailFilter
.
Các hàm trong Scala cung cấp cách hợp nhất 2 hàm, nó sẽ giúp chúng ta ở đây: Cho 2 hàm f
và g
, f.compose(g)
trả về một hàm mới, khi được gọi, đầu tiên là gọi hàm g
và sau đó áp dụng f
trên kết quả của nó. Tương tự, f.andThen(g)
trả về một hàm mới, khi được gọi, sẽ áp dụng g
vào trong kết quả hàm f
.
Chúng ta có thể sử dụng cách này để tạo notSentByAnyOf
giúp tránh việc trùng lặp code
val notSentByAnyOf = sentByOneOf andThen(g => complement(g))
Điều này có nghĩa là chúng ta yêu cầu một hàm mới, đầu tiên áp dụng hàm sentByOneOf
với các tham số đầu vào (một Set[String]
) và sau đó kết quảEmailFilter
được áp dụng hàm vào complement
.
Sử dụng cú pháp placeholder của Scala cho các hàm ẩn (anonymous function), chúng ta có thể viết chính xác hơn như sau:
val notSentByAnyOf = sentByOneOf andThen(complement(_))
Tất nhiên bạn sẽ nhận thấy rằng với hàm complement
, bạn cũng có thể thực hiện maximumSize
theo minimumSize
thay vì sử dụng hàm sizeConstraint
. Tuy nhiên, để sau này linh động hơn, nó cho phép bạn tuỳ ý chỉ định kiểm tra kích thước của nội dung mail.
Hợp nhất các điều kiện kiểm tra
Một vấn đề khác với phần lọc email của chúng ta là chúng ta chỉ có thể truyền EmailFilter
duy nhất đến hàm newMailsForUser
. Chắc chắn người dùng muốn cấu hình nhiều tiêu chí khác nhau. Chúng ta cần một cách để tạo tổng hợp các điều kiện kiểm tra mà trả về true
với 3 trường hợp none
, every
và any
Đây là cách để thực hiện những hàm này:
def any[A](predicates: (A => Boolean)*): A => Boolean = a => predicates.exists(pred => pred(a)) def none[A](predicates: (A => Boolean)*) = complement(any(predicates: _*)) def every[A](predicates: (A => Boolean)*) = none(predicates.view.map(complement(_)): _*)
Hàm any
trả về true
nếu ít nhất một predicates nắm giá trị là a
. Hàm none
đơn giản là trả về kết quả ngược lại với hàm any
ở trên bằng cách gọi hàm complement
với giá trị đầu vào là kết quả trả về hàm any
. Cuối cùng là hàm every
kiểm tra tất cả đều true bằng cách dùng hàm none
để kiểm tra complement
của từng predicate
.
Chúng ta có thể sử dụng các hàm này để tạo tập hợp EmailFilter mà người dùng cấu hình
val filter: EmailFilter = every( notSentByAnyOf(Set("johndoe@example.com")), minimumSize(100), maximumSize(10000) )
Trường hợp khác
Một ví dụ khác về hợp nhất hàm, xem lại yêu cầu ví dụ của chúng ta. Như một bên cung cấp dịch vụ mail miễn phí, chúng tôi không chỉ muốn cho phép khách hàng cấu hình lọc thông tin email, mà còn thực hiện một số thao tác trên mail do người dùng gửi đến. Đây là những hàm đơn giản Email => Email
. Một số biến đổi có thể như sau:
val addMissingSubject = (email: Email) => if (email.subject.isEmpty) email.copy(subject = "No subject") else email val checkSpelling = (email: Email) => email.copy(text = email.text.replaceAll("your", "you're")) val removeInappropriateLanguage = (email: Email) => email.copy(text = email.text.replaceAll("dynamic typing", "**CENSORED**")) val addAdvertismentToFooter = (email: Email) => email.copy(text = email.text + "\nThis mail sent via Super Awesome Free Mail")
Bây giờ, chúng ta sẽ cấu hình pipeline
như yêu cầu ở trên, chúng ta có thể sử dụng andThen
hoặc là dùng phương thức chain
mang lại hiệu quả tương tự, phương thức này được định nghĩa trong companion object Function
val pipeline = Function.chain(Seq( addMissingSubject, checkSpelling, removeInappropriateLanguage, addAdvertismentToFooter))