Functor là gì? Tôi có cần biết đến functor?

Bài viết được sự cho phép của tác giả Tống Xuân Hoài

Vấn đề

Khái niệm Functor là một bước đệm để từ đó giúp cho bạn khám phá ra những điều mới mẻ trong thế giới lập trình hàm. Vậy thì functor là gì và nó mang lại lợi ích gì trong lập trình?

Functor là gì?

Về bản chất, functor là một cấu trúc dữ liệu mà bạn có thể map qua chúng để áp dụng một hàm vào từng phần tử với mục đích sửa đổi dữ liệu. Nhưng một điều quan trọng là dữ liệu đó được chứa trong một “vùng chứa”, để có thể sửa được giá trị thì các hàm phải lấy ra, sửa đổi rồi đặt giá trị vào “vùng chứa”.

Functor hay còn được kí hiệu là fmap. Đây là định nghĩa chung của fmap:

fmap :: (A -> B) -> Wrapper(A) -> Wrapper(B)

Hàm fmap nhận một hàm (A -> B) biến đổi hàm Wrapper(A) thành Wrapper(B) sau khi đã thực hiện việc biến đổi các giá trị A thành B. Để hiểu rõ hơn bạn có thể xem hình dưới:

Wrapper

Chúng ta thấy giá trị 1 được lấy ra khỏi “vùng chứa” -> áp dụng hàm -> đặt lại vào “vùng chứa”.

Về cơ bản fmap sẽ trả về một bản sao mới của “vùng chứa” tại mỗi lần gọi nên nó có thể coi là bất biến.

Đó là lý thuyết, hãy để tôi lấy một ví dụ cụ thể: Biểu diễn phép tính 2 + 3 = 5 bằng functor.

  10 tip tối ưu code trên JavaScript mà web developer nào cũng nên biết

  Ứng dụng thuật toán và cấu trúc dữ liệu lúc đi làm

Đầu tiên tôi sẽ xây dựng một class Wrapper nhận vào một giá trị, class này có hai methods: fmap để biến đổi và indentity để lấy ra giá trị:

class Wrapper {
  constructor(value) {
    this.value = value;
  }

  fmap(fn) {
    return new Wrapper(fn(this.value));
  }

  identity() {
    return this.value;
  }

  map(fn) {
    return fn(this.value);
  }
}

fmap nhận vào một hàm, dùng hàm đó để biến đổi value và lại đặt vào Wrapperidentity chỉ đơn giản là trả về value.

Tôi sẽ sử dụng curry function để thực hiện phép cộng. Nếu chưa biết về curry bạn có thể đọc bài viết Curry function là gì? Một món “cà ri” ngon và làm sao để thưởng thức nó?.

const plus = a => b => a + b;
const plus3 = plus(3);

const two = new Wrapper(2);
const sum = two.fmap(plus3); // Wrapper(5)
sum.identity(); // 5

Đến đây thì các bạn có phát hiện ra điều gì thú vị không? Đúng rồi đó, sum vẫn có thể tiếp tục sử dụng được hàm fmap hay nói cách khác là khi kết quả xử lý trả về một đối tượng là Wrapper thì chúng ta sẽ không phải lo lắng về tính liên tục của dữ liệu sau xử lý. Tôi có thể tiếp tục cộng trừ nhân chi một cách liên tiếp:

const multi = a => b => a * b;
const multi5 = multi(5);
sum.fmap(multi5).identity(); // 25

Khi kết quả của hàm fmap trả về là một Wrapper thì nó đảm bảo được rằng kết quả vẫn mang những tính chất của Wrapper.

Xem thêm các việc làm tuyển dụng Javascript hấp dẫn tại TopDev

Thật thú vị phải không? Ý tưởng về chuỗi các hàm có làm bạn liên tưởng đến hàm map hay filter trong Javascript? Thật vậy đó chính xác là những triển khai của functor.

map :: (A -> B) -> Array(A) -> Array(B)
filter :: (A -> Boolean) -> Array(A) -> Array(A)

map và filter được coi là functor bởi chúng có những đặc điểm của functor:

  • Giống nhau
  • Duy trì cấu trúc
  • Loại giá trị

Functor cần phải đảm bảo được một số thuộc tính quan trọng:

Không gây ra side effect: có thể fmap qua một hàm identity để có được cùng một giá trị trong một ngữ cảnh. Điều này chứng minh được rằng chúng không gây ra side effect và vẫn bảo toàn cấu trúc của giá trị được bao bọc. Bạn có thể hiểu identity là một hàm chỉ đơn giản là trả về giá trị mà nó nhận được.

Wrapper('Get Functional').fmap(x => x); // Wrapper('Get Functional')

Thứ hai, chúng phải có thể kết hợp được. Tức là có thể fmap được liên tục. Để đảm bảo được điều này, các cấu trúc điều khiển ví dụ như fmap phải không được ném ra exception, thay đổi các phần tử trong danh sách hoặc thay đổi hành vi của một hàm. Mục đích là tạo ra một ngữ cảnh cho phép bạn thao tác vào các giá trị mà không làm thay đổi giá trị ban đầu. Điều này thể hiện rõ ràng trong việc hàm map biến đổi mảng này thành mảng khác mà không làm thay đổi mảng ban đầu.

Tuy nhiên trong lập trình không phải lúc nào ta cũng có dữ liệu hoàn hảo, mà chúng ta vẫn phải xử lý những exception, những giá trị như null, undefined… Lúc này việc áp dụng các functor sẽ không còn hoàn hảo nữa.

const div = a => b => b/a;
const subtr = a => b => a - b;
const plus = a => b => a + b;

const divided5 = div(5);
const subtr2 = subtr(2);
const plus3 = plus(3);

const two = Wrapper(2);
two.fmap(subtr2).fmap(divided5).fmap(plus3); // Wrapper(NaN)

Tổng kết

Functor là một cấu trúc dữ liệu lưu trữ dữ liệu ở trong một “vùng chứa”, nó cung cấp các phương thức để thao tác với dữ liệu ở trong “vùng chứa” đó. Sử dụng functor chúng ta sẽ đảm bảo được đầu ra của dữ liệu sẽ không bị thay đổi kiểu, nó giống với việc hàm map nhận vào một array và luôn luôn trả ra một array.

Bài viết gốc được đăng tải tại 2coffee.dev

Có thể bạn quan tâm:

Hàng loạt việc làm IT lương cao trên TopDev đang chờ bạn, ứng tuyển ngay!