+2

[Functional Programming + Elm] Bài 4 - Functor & Applicative

Khi học bất kỳ ngôn ngữ lập trình nào, sau khi đã nắm bắt được các kiểu dữ liệu cơ bản và cú pháp hỗ trợ khai báo trừu tượng Abstraction để tạo ràng buộc khi thiết kế tổng quan phần mềm, thì bước tiếp theo mà chúng ta cần quan tâm là tìm hiểu về một số dạng thức triển khai pattern để áp dụng cho tiến trình thiết kế.

Trong môi trường Functional nói riêng thì chúng ta có một số pattern rất phổ biến, được triển khai nhờ khái niệm class mà chúng ta đã nói tới trước đó; Và ở đây chúng ta sẽ tìm hiểu pattern đầu tiên có tên là Functor.

Functor

Xuất phát với hàm map mà chúng ta đã biết trong module List, thực tế thì đây là hàm HOF phổ biến nhất trong môi trường Functional bởi có rất nhiều trường hợp chúng ta sẽ không viết những lời gọi hàm trực tiếp làm việc với kiểu dữ liệu nào đó.

Tức là thay vì trực tiếp gọi một hàm xử lý dữ liệu ví dụ như Char.toCode và truyền vào một giá trị c : Char thì chúng ta lại thường có logic hoạt động của code ở runtime sẽ là Char.map f c, với f được truyền tới từ đâu đó và có thể sẽ là Char.toCode.

Bằng cách sử dụng map làm HOF và điều khiển việc gọi hàm trên c : Char như vậy thì chúng ta sẽ có thể kiến trúc chương trình linh động hơn. Và kiểu Char nếu được định nghĩa hàm map để có thể viết code triển khai như vậy thì sẽ được gọi là một kiểu thuộc class Functor.

Functorclass bao gồm các type có thể được đối chiếu bởi map.

Trong Haskell hay PureScript thì Type Class được hỗ trợ ở cấp độ cú pháp và có rất nhiều Type Class đã được định nghĩa sẵn trong thư viện tiêu chuẩn, bao gồm cả Functor mà chúng ta đang nói tới ở đây. Và bây giờ chúng ta sẽ vay mượn định nghĩa class Functor sang Elm cho bất kỳ kiểu dữ liệu a nào tham gia vào sẽ có thể map để đối chiếu các giá trị tới kiểu b bất kỳ.

module Class exposing (YesNo, Functor)

type alias Functor a b c d =
   { map : (a -> b) -> c -> d }

-- type alias YesNo ...

Và code triển khai để sử dụng vẫn class Functor sẽ được viết trong module Book ở ví dụ trước đó.

module Book exposing (..)
import Class exposing (..)


type alias Book =
   { title  : String
   , author : String
   , rating : Float
   }


instanceFunctor : Class.Functor Book any Book any
instanceFunctor = Class.Functor map

map : (Book -> any) -> Book -> any
map func abook = func abook

Về việc gắn các thông tin định kiểu ở dòng instanceFunctor : Class.Functor Book any Book any cho các vị trí Type Variable thì ban đầu chúng ta có thể viết instanceFunctor : Class.Functor a b c d. Sau đó, chúng ta cứ tiến hành viết code triển khai hàm Book.map trước, để suy nghĩ về thao tác khi sử dụng hàm map và chọn định kiểu phù hợp cho các yếu tố.

Ở đây chúng ta có thể thiết kế để map nhận vào hàm func không có hiểu biết gì về kiểu Book và chỉ làm việc trên các kiểu primitive; Sau đó thì việc áp dụng func cho các trường dữ liệu nào sẽ do logic của map quy định. Hoặc, cũng có thể thiết kế để map nhận vào hàm func chứa logic làm việc trực tiếp với kiểu Book như trên.

Sau khi đã có thông tin định kiểu của hàm map(Book -> any) -> Book -> any, thì chúng ta đặt ngược lại về định nghĩa của instanceFunctor để đảm bảo logic triển khai class Functor được nhất quán và trình biên dịch sẽ không báo lỗi.

Và như vậy là chúng ta đã có thể sử dụng Book.map ở bất kỳ vị trí nào trong chương trình và truyền vào một lambda đối chiếu.

module Main exposing (main)
import Book exposing (..)
import Html exposing (Html, text)


main : Html message
main =
   let yogaBook = (Book "Yoga" "Patanjali" 9.9)
       showRating = (\abook -> "Rating: " ++ String.fromFloat abook.rating)
   in  text <| Book.map showRating yogaBook
   
-- "Rating: 9.9"

Đó là Functor. Bây giờ chúng ta hãy nói về Applicative.

Applicative

Khái niệm Applicative có tên gọi đầy đủ là Applicative Functor, và được sử dụng để nói về các kiểu Functor là các kiểu cấu trúc dữ liệu có thể lưu trữ một hoặc nhiều hàm f khác nhau.

Ví dụ như một Maybe có thể có chứa một hàm (number -> number) để tương tác với các giá trị số học.

> increment = (+) 1
<function> : number -> number

> maybeFunc = Just increment
Just <function> : Maybe (number -> number)

À... và điều kiện kèm theo là maybeFunc như mô tả ở trên cần phải có thể được áp dụng lên một maybeNumb để trả về một giá trị cùng kiểu Maybe number như sau:

> maybeNumb = Just 9
Just 9 : Maybe number

> Maybe.apply maybeFunc maybeNumb
-- Expected: Just 10 : Maybe number
-- Error: cannot find `Maybe.apply`

Chúng ta có thể đọc thao tác Maybe.apply ở đây là - áp dụng logic của Hàm đang được lưu trữ trong maybeFunc lên giá trị đang được lưu trữ trong maybeNumb. Và trong trường hợp này thì kiểu dữ liệu Maybe được xem là một Applicative Functor, hay là một thành viên của class Applicative.

Như vậy là chúng ta có Applicative là các kiểu dữ liệu dạng vỏ bọc wrapper như Maybe, List, v.v... có thể lưu trữ các hàm có tên hoặc các lambda và có khả năng tạo tương tác với chính kiểu wrapper đó bằng hàm HOFapply. Logic xử lý của apply ở đây là tách lấy các hàm f đang được lưu trữ trong wrapper đầu tiên và map sang các giá trị đang được lưu trữ trong wrapper thứ hai.

Phần code ví dụ ở trên chỉ là để mô phỏng cú pháp sử dụng và định nghĩa Applicative, còn trên thực tế thì chúng ta không có hàm apply trong module Maybe để sử dụng như vậy. Việc viết thêm các hàm mở rộng cho module Maybe là không khả thi trong môi trường Elm và chúng ta sẽ phải tạo ra một module Extension.MaybeExt để mở rộng thêm tính năng cho kiểu Maybe sẵn có.

Tuy nhiên, trước hết hãy bắt đầu với việc định nghĩa class Applicative trong Elm bằng record như chúng ta đã định nghĩa class Functor trước đó.

module Class exposing (..)

type alias Applicative a b c d e =
   { apply : a -> b -> c
   , map   : (d -> e) -> b -> c
   }

-- type alias Functor ...
-- type alias YesNo ...

Ở đây chúng ta vẫn có yếu tố kế thừa từ Functor là hàm map, tuy nhiên trong Applicative thì apply quan trọng hơn và sẽ được sử dụng làm mốc triển khai logic trước. Bây giờ chúng ta thực hiện khai báo instance trong module Extension.Maybe và viết code chi tiết cho applymap để có kết quả hoạt động như dự kiến.

module Extension.Maybe exposing (..)
import Class exposing (..)

instanceApplicative : Applicative a b c d e
instanceApplicative = Applicative apply map

Điểm đầu tiên cần lưu ý trong code triển khai là chúng ta có hàm maybeFunc chỉ đảm nhiệm vai trò làm việc với giá trị được đặt bên trong kiểu wrapper và không có hiểu biết gì về cấu trúc của wrapper được sử dụng là Maybe, List, hay Record, v.v...

-- instance ...

apply : Maybe (a -> b) -> Maybe a -> Maybe b
apply maybeFunc maybeAny =
   case maybeFunc of
      Nothing   -> maybeAny
      Just func -> map func maybeAny

Như vậy hàm apply : a -> b -> c khai báo trong class Applicative sẽ có thông tin định kiểu cụ thể là tham số đầu tiên có dạng hàm (a -> b) được đặt trong wrapper Maybe. Logic xử lý của apply sẽ nhận vào một Maybe tiếp theo có chứa dữ liệu là giá trị thuộc kiểu a nào đó tương thích với maybeFunc. Và kết quả trả về là một Maybe mới chứa giá trị thuộc kiểu b cũng tham chiếu từ maybeFunc.

Lúc này chúng ta đã có thể viết các thông tin định kiểu cụ thể này vào vị trí khai báo instance và vẫn để các Type Variable còn lại là de chưa biết chính xác. Sau đó tiếp tục viết code cho hàm map:

-- apply : ...

map : (a -> b) -> Maybe a -> Maybe b
map func maybe =
   case maybe of
      Nothing  -> Nothing
      Just any -> Just (func any)

Xuất phát từ vị trí Functor là tham số thứ hai Maybe a để đối chiếu tới giá trị mới là Maybe b. Như vậy hàm func sẽ có thông tin định kiểu là (a -> b) để có logic phù hợp. Như vậy tổng kết lại chúng ta sẽ có thông tin định kiểu đầy đủ cho thao tác khai báo instance... là:

instanceApplicative : Applicative (Maybe (a -> b)) (Maybe a) (Maybe b) a b
instanceApplicative = Applicative apply map

Bây giờ chúng ta đã có thể sử dụng elm reactor hoặc elm repl để kiểm tra hoạt động của apply.

> maybeFunc = Just ((+) 1)
Just <function> : Maybe (number -> number)

> maybeAny = Just 9
Just 9 : Maybe number

> MaybeExt.apply maybeFunc maybeAny
Just 10 : Maybe number

Một ví dụ khác về Applicative là tạo apply cho kiểu List. Giả sử chúng ta có một funcList chứa các hàm func : (number -> number) và một numbList khác chứa các giá trị number như sau:

> add : number -> number -> number
> add a b = a + b

> funcList = [ add 1, add 2, add 3, add 4, add 5, add 6, add 7, add 8, add 9]
> [ ... ] : List (number -> number)

> numbList = [     8,     7,     6,     5,     4,     3,     2,     1,     0]
> [ ... ] : List number

Với dạng thức triển khai Applicative như trên thì chúng ta sẽ có thể tạo apply để áp dụng các hàm ở funcList lên các giá trị ở numbList với tỉ lệ 1:1 và trả về mảng kết quả như sau:

> ListExt.apply funcList numbList
[9,9,9,9,9,9,9,9,9] : List number

Logic của map ở đây vẫn sẽ duy trì giống như List.map sẵn có và không cần phải định nghĩa lại. Tuy nhiên apply thì sẽ có khá nhiều thao tác cần thực hiện thêm so với trường hợp của Maybe để có logic hoạt động như vậy. Bạn có thể sử dụng trường hợp này để luyện tập triển khai Applicative và chúng ta sẽ tạm dừng tại đây để chuyển sang những khái niệm Functional tiếp theo.

// -- zip : [a] -> [b] -> [[a, b]]
Array.prototype.zip = function (thatArray) {
   var thisArray = this;
   var pairCounter = (thisArray.length < thatArray.length)
	               ? thisArray.length
                   : thatArray.length;
   // -- pairing
   var accumulator = new Array (pairCounter);
   for (var i = 0; i < pairCounter; i += 1) {
      var thisValue = thisArray[i];
      var thatValue = thatArray[i];
      accumulator[i] = [thisValue, thatValue];
   }
   return accumulator;
}

// -- zip-test
var fst = ['a','b','c','d','e','f','g','h','i'];
var snd = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21];
var zip = fst.zip (snd);
console.log ("-- zip-test :");
console.log (zip);
// -- zip-test :
// [['a',1],['b',2],['c',3],['d',4],['e',5],['f',6],['g',7],['h',8],['i',9]]


// -- apply : [(a -> b)] -> [a] -> [b]
Array.prototype.apply = function (dataArray) {
   var funcArray = this;
   var pairArray = funcArray.zip (dataArray);
   // -- applying
   return pairArray.map ((pair) => {
      var [func, data] = pair;
      return func (data);
   });
}

// -- apply-test
var add = (a) => (b) => a + b;
var funcArray = [add(1),add(2),add(3),add(4),add(5),add(6),add(7),add(8),add(9)];
var dataArray = [    8 ,    7 ,    6 ,    5 ,    4 ,    3 ,    2 ,    1 ,    0 ];
var resultArray = funcArray.apply (dataArray);
console.log ("-- apply-test :");
console.log (resultArray);
// -- apply-test :
// [9,9,9,9,9,9,9,9,9]

[Functional Programming + Elm] Bài 5 - Monad & Monoid


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.