01 Apr 2018

Ứng dụng currying trong scala

Currying

Theo như wikimedia

In mathematics and computer science, currying is the technique of translating the evaluation of a function that takes multiple arguments (or a tuple of arguments) into evaluating a sequence of functions, each with a single argument. Currying is related to, but not the same as, partial application.

Như vậy, nếu như chúng ta có một function:

f(x, y) = x+y

thì ta có thể thực thi hàm tuần tự: f(x, y) = h(x) + g(y) trong đó h(x) = xg(y) = y Nếu trong một thời điểm nào đó, ta có được giá trị x=2, thì khi đó f(x,y) = x + y trở thành f(y) = 2 + y hay f(y) = 2 + g(y)

Trong scala, để biểu diễn f(x, y) thành một curried function, ta khai báo hàm thành f(x)(y) thay vì f(x, y)

def f(x: Int)(y: Int): Int = x + y 

Khi có được giá trị của x, ta gọi f như sau:

scala> def f(x:Int)(y: Int): Int = x + y
f: (x: Int)(y: Int)Int

scala> val x = 2
x: Int = 2

scala> val gy = f(x)
gy: Int => Int = <function1>

scala> gy(2)
res0: Int = 4

Để ý return type khi gọi f(x)_, ta có được function1, function1 là một type được định nghĩa sẵn của scala Function1, hiểu đơn giản Function1 là function có 1 tham số đầu vào, với apply method là

def apply(y: Int) = 2 + y

Mở rộng cách triển khai bên dưới của curried function f(x)(y) bên trên, ta có thể hiểu khi gọi f(x) với x=2, f(x) sẽ được biểu diễn như sau:

def f(x: Int): Function1[Int, Int] = {
      new Function[Int, Int] {
        def apply(y: Int): Int = {
          y + x
        }
      }
}

Khi gọi f với x=2, ta có kết quả tương tự như trên:

scala> val gy = f(2)
gy: Int => Int = <function1>

scala> gy(1)
res18: Int = 3

Ứng dụng

1. Cách đơn giản nhất

Khi lập trình phần mềm, chúng ta thường gặp trường hợp phải gọi một hàm với hai hoặc nhiều tham số đầu vào, nhưng tại thời điểm nhất định, chỉ có một giá trị duy nhất. Ví dụ như hàm f(x, y) bên trên. Tại thời điểm T1 ta chỉ có giá trị của x, khi gọi f(x = 2) với ngôn ngữ như Java hoặc Python sẽ gặp lỗi, vì các ngôn ngữ này không hỗ trợ curried function. Ta buộc phải truyền giá trị của x đi khắp nơi hoặc lưu lại ở một biến global nào đó, sau đó ở thời điểm T2 tìm được giá trị của y mới gọi f để xử lí.

Thử tưởng tượng nếu như chúng ta có một flow xử lí thông tin như sau:

1. Request đến một api endpoint để lấy thông tin tất cả người dùng 
2. Parse response trả về để lấy id người dùng
3. Tạo request tiếp theo đến api endpoint khác để lấy thông tin cụ thể của từng người dùng (UserDetail)
4. Parse UserDetail để lấy địa chỉ email của người dùng
5. Gọi đến api endpoint cuối cùng với userid để lấy nội dung email, gửi nội dung email cho người dùng với id tương ứng.

Cho đến cuối cùng flow trên sẽ được đơn giản hoá thành một hàm như bên dưới:

def process(url: String)(op: HttpResponse => Unit) = ???

Hàm này nhận vào một Http request, và xử lí Http Response, và trả về giá trị Unit (thay vì void type ở C/C++/Java). Tham số op: HttpResponse => Unit là bất cứ hàm nào nhận vào tham số là HttpResponse và trả về typeUnit. Nếu như dùng currying, cách triển khai như sau:

val totalUserApi = "http://apiserver.com/totalusers"
val userDetailApi = "http://apiserver.com/getuserdetail"
val emailApi = "http://apiserver.com/getemail"


def process(url: String)(op: HttpResponse => Unit) = {
  val request = HttpRequest(url)
  val response = MakeRequest(request) getResponseEntity 
  op(response)
}

def sendEmail(email: String)(httpResponse: HttpResponse) = {
  emailContent = getContent(httpResponse)
  send(email, emailContent)
}

def getEmail(httpResponse: HttpResponse) = {
   val email = getEMail(httpResponse)
   val sender = sendEMail(email) _ // currying
   process(s"$emailApi?)(sender)
}

def parseToTalUsersResponse(httpResponse: HttpResponse) = {
  val ids = do_something_to_parse_json_string_in_response_entity(httpResponse)
  ids.foreach({ id => 
    process(s"$userDetailApi?id=$id")(getEmail) 
  }) 

}

// do 1st step: 
process(totalUserApi)(parseToTalUsersResponse)

Tất cả các bước trên đều là async, nhưng vẫn readable. Nếu không sử dụng currying, chúng ta buộc phải tìm cách nào đó để chuyển flow này thành tuần tự, hoặc phải viết thêm một hàm trả về function : HttpResponse => Unit như bên dưới:

def creatSender(email: String): HttpResponse => Unit = {
  response => send(email, response)
}

Sau đó viết lại hàm getEmail:

getEmail(httpResponse: HttpResponse) = {
   val email = getEMail(httpResponse)
   val sender = creatSender(email)  // no currying
   process(s"$emailApi?)(sender)
}

Phần lớn mọi người khi nhìn vào hàm có return type là function luôn cảm thấy khó hiểu.

2. implicit parameter

Internal implementation của scala và các thư viện viết bằng scala ứng dụng currying rất nhiều, chủ yếu thông qua implicit parameter. Implicit parameter trong scala là một ứng dụng của currying + implicit cho phép compiler insert một tham số còn thiếu vào lời gọi hàm.

Có thể hiểu một cách tổng quát, với Scala, một hàm được khai báo với 2 hoặc nhiều tham số đầu vào, nhưng ta có thể sử dụng hàm này với chỉ 1 tham số đầu vào duy nhất, phần còn lại sẽ được compiler lo liệu, bằng cách tìm kiếm implicit trong các giá trị implicit phù hợp được define ở phạm vi cho phép, và dùng implicit này để insert vào lời gọi hàm.

Implicit parameter đuợc đánh giá là ứng dụng advance của scala, tuy nhiên do chúng ta sẽ gặp rất nhiều khi làm việc với scala cũng như các thư viện viết bằng scala, nên cũng nên tìm hiểu về khái niệm này.

Một ví dụ ứng dụng implicit parameter: khi làm việc với một thư viện hoặc hàm cần tham số đầu vào là một type tương ứng

case class Custom(words: String) 

và một method say yêu cầu Custom là tham số đầu vào, chúng ta dùng say như sau:

val words = "Hello scala"
val obj = Custom(words)
say(obj)

Dễ dàng nhận thấy words được wrap bên trong Custom chỉ để người thiết kế thư viện ấn định một type, việc này cần thiết đối với trường hợp ứng dụng Pattern matching, tuy nhiên đối với người sử dụng, việc tính toán để có được kết quả là String, sau đó tất cả mọi lần sử dụng đều phải new Custom object với String có được là khá phiền phức, nếu như ta chỉ sử dụng một object type duy nhất là Custom, có thể sử dụng implicit parameter trong trường hợp này, và do implicit parameter áp dụng currying, đây cũng được coi là một ứng dụng của currying:

Define một method để implicit convert String to Custom

implicit def stringToCustom(s: String) = Custom(s)
// và implement hàm cần sử dụng say method với implicit parameter

def sayToTheWorld(words: String)(implicit worldToObject: String => Custom) = {
  say(words)
}

Dùng method đã define

val words = "Hello Scala"
sayToTheWorld(words)

Để ý ta sử dụng hàm say với tham số đầu vào là words, với type là String. Khi compiler biên dịch đoạn code trên, gặp sayToTheWorld(words) sẽ bị lỗi, trước khi dừng việc biên dịch và thông báo lỗi, compiler sẽ tìm kiếm trong scope đang thực thi xem có sự hiện diện của một implicit nào có input là String và output là Custom không? Nếu có, implicit này sẽ được insert tự động vào lời gọi hàm sayToTheWorld và ngay cả say trong body của hàm sayToTheWorld

Bản thân Core implementation của scala cũng sử dụng vô số implicit parameter, từ việc chuyển đổi Java String sang IndexSeq, cho đến Fuctures/Promises với ExecutionContext

Kết

Bài này chỉ nhắc đến 2 ứng dụng nhỏ của currying, và sẽ được cập nhật khi có thời gian. Currying là một trong những feature rất hay khi chuyển từ Java sang scala, ngoài rất nhiều những khái niệm khác như: Object, trait, case class, pattern matching…