Scala – Higher-order function

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 fg, 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, everyany

Đâ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))

 

 

You May Also Like

About the Author: Nguyen Dinh Thuc

Leave a Reply

Your email address will not be published.