Functional Programing (FP) hay còn gọi là lập trình hàm đã ra đời từ trong ngành phần phát phần mềm từ những ngày đầu tiên. Bạn đã thực sự nghe nhiều về nó? Hay là phỏng vấn lúc nào cũng được nghe những câu hỏi: OOP là gì? Tính chất của OOP?. 😉 Những gì được viết tiếp theo đây là một bài viết giới thiệu về các Concepts của FP và cung cấp hiểu biết thực tế với các ví dụ trong dart (Ngôn ngữ dùng để code Flutter) với mục đích giúp người đọc làm quen với FP hoặc nhận ra mình đang sử dụng các Concepts FP hằng ngày. 😵‍💫

1. What is FP?

(Ảnh sưu tầm)

“Bạn hãy hình dung, các khối nhỏ LEGO brick kia chính là các Hàm , còn con khổng long thành phẩm kia chính là thành phẩm App Flutter  của bạn. Ta muốn xây dựng một App Flutter thì phải bắt đầu từ những viên gạch nhỏ đó là những hàm.” 😝

Đại khái chúng ta lấy hàm làm đơn vị cơ bản để tổ chức code. Lập trình hàm có nghĩa là sử dụng các hàm phối hợp với nhau để có hiệu quả tốt nhất trong việc tạo phần mềm sạch và có thể bảo trì. Cụ thể hơn, lập trình hàm là một tập hợp các phương pháp tiếp cận để coding, thường được mô tả như một mô hình lập trình (Programming Paradigm).

Và giờ chúng ta cùng đến với miền đất hứa của Functional Programming, bỏ qua vùng đất lạnh lẽo, nhàm chán của Imperative Programming đã quá quen thuộc.

Lập trình hàm đôi khi được định nghĩa đối lập với lập trình hướng đối tượng (OOP) và lập trình thủ tục (Procedure Programming). Điều đó là sai lầm vì những cách tiếp cận này không loại trừ lẫn nhau và hầu hết các hệ thống có xu hướng sử dụng cả ba.

Học một cái mới đúng là lằng nhằng tuy nhiên các bạn cũng đừng quá nôn nóng. Hãy dành thời gian đọc những gì tôi viết , cũng như dành thời gian để hiểu những đoạn code ví dụ. Bạn có thể tạm dừng sau mỗi đoạn để nghiền ngẫm hoặc chạy thử, sau đó hãy quay lại và tiếp tục.

<aside> 💡 Điều quan trọng là bạn thực sự hiểu. 😀

</aside>

2. Purity

int add(int x, int y) {
  return x + y;
}

Pure Function

Lý tưởng trong lập trình hàm là các Pure Function (hàm thuần khiết). Nhắc đến Purity(sự thuần khiết) trong FP thì là nhắc đến Pure function 😃. Vậy thì nói về định nghĩa thì nó dài dòng chúng ta sẽ bắt đầu qua các ví dụ.

<aside> 💡 Hầu hết các Pure Function đều có ít nhất một tham số.

</aside>

//Hàm này chỉ trả về giá trị là 1 chi bằng tạo thành biến constant :)

int justOne() {
  return 1;     
}

const int one = 10; 

<aside> 💡 Một Pure Function chỉ có giá trị sử dụng khi có giá trị trả về.

</aside>

final add(a, b) {
	final c = x + y;   // Hàm này có tác dụng gì nhỉ? 😵
}

<aside> 💡 Pure Function sẽ luôn trả về cùng output với cùng input, bất kể có thực hiện bao nhiêu lần.

</aside>

final add(x, y) {
	return x + y;
}

print(add(1,1)); // kết quả là 2
print(add(1,1)); // Vẫn là 2 🙂
print(add(1,2)); // Luôn luôn là 2 😑

<aside> 💡 Pure Function đảm bảo việc hàm sẽ không có Side Effects.

</aside>

final purchaseSuccess() {
	loading.value = false; // biến này ở mô ra vậy? 
	...
}

Side Effects

Trong lập trình thì hầu như Side Effects xuất hiện ở khắp mọi nơi. Việc này làm cho việc debug khó khăn vì biến hay là state có thể được truy cập khắp mọi nơi. Mỗi lần xảy ra lỗi do biến hay state thay đổi không mong muốn là phải mò đi tìm. ☹️

Trong pure function ngoại trừ giá trị trả về và các tham số trong hàm bị thay đổi, các giá trị khác mà không phụ thuộc hàm này bị thay đổi nghĩa là có side-effect.

Vậy mày nói tao xem làm có cách nào cập nhật loading mà không gây ra side effect ? 😅

FP không thể loại trừ hoàn toàn Side Effects, mà chỉ có thể cô lập chúng. Vì các phần mềm phải giao tiếp, thao tác với người dùng , nên một số thành phần bắt buộc phải impure. Mục tiêu của FP là tối thiểu hóa hết mức có thể số lượng impure code và tách biệt chúng hoàn toàn khỏi các phần khác của chương trình. How? 🙂

3. Immutability

void main() {
  int x = 0;  
  x = x + 1;
  print(x);
	// Mới học toán xong nhìn thấy sai sai? 😒
	// x = 0 rồi mà còn x = x + 1?
}

<aside> 💡 Trong Functional Programming không có khái niệm về biến (variable)

</aside>

Giờ thì để lưu trữ các giá trị chúng ta phải sử dụng hằng số (constant), tức là nếu biến x đã lưu một giá trị nào đó (là 1 chẳng hạn), thì giá trị của biến x sẽ không thay đổi, vẫn giữ nguyên là 1 như ban đầu được set. chỉ có read only thôi 😂

Ban đầu đọc mình cũng lo lắng về bộ nhớ, nhưng mà bạn có thể an tâm khi trong FP, x thường chỉ là biến cục nên thời gian tồn tại thường rất ngắn. Tuy nhiên trong suốt thời gian tồn tại, giá chị của x là bất biến. 💪

Tiếp thep

<aside> 💡 Functional Programming sử dụng đệ quy cho việc lặp.

</aside>

var acc = 0;
  for (var i = 1; i <= 10; ++i) acc += i;
  print(acc); //  55

// without loop construct or variables (recursion)
  sumRange(start, end, acc) {
    if (start > end) return acc;
    return sumRange(start + 1, end, acc + start);
  }
  print(sumRange(1, 10, 0)); //  55

Nghe tới đây thôi thấy mệt mệt. Đệ quy mà thay cho vòng lặp thì thôi code một mình luôn đi =)) Hèn gì chả ai theo ông này toàn qua OOP chơi. Phần này mình nói về FP nên sẽ không đi sâu vào đệ quy vì nó dễ gây chán lắm.

Tuy nhiên một lợi ích rõ ràng của tính bất biến, đó là nếu bạn phải truy cập đến một giá trị bất kỳ trong ứng dụng và bạn chỉ có quyền đọc nó, đương nhiên là không ai có thể thay đổi giá trị của nó. Và do đó sẽ tránh được những thay đổi không mong muốn.(Đây là concept giảm Side effect)

Và nếu chương trình hỗ trợ đa luồng (multi-threaded), thì sẽ không có bất kỳ một thread nào có thể khiến bạn đau đầu. Giá trị được set sẽ là hằng số, và nếu bất kì một thread nào muốn thay đổi nó, thread đó sẽ phải tạo một giá trị mới từ cái cũ.

<aside> 💡 Tính bất biến tạo ra các dòng code đơn giản hơn và an toàn hơn.

</aside>

Giờ viết unit test cho pure function là nhiệm vụ dễ chịu như dạo chơi cùng một thiếu nữ ngây thơ trong trắng vậy!

Immutability và Purity là 2 đặc trưng cơ bản nhất của Functional Programming, cho phép phân biệt với các cơ chế lập trình khác. Đi theo con đường này nhất định phải nắm được "immutable" và “pure”.

 

4. Higher-Order Function

Quá quen thuộc rồi , chúng ta sẽ xem ví dụ sau:

// Gán một hàm vào biến

int add1(int x, int y) {
  return x + y;
}

int Function(int, int) add2 = (int x, int y) {
  return x + y;
};

int Function(int, int) add3 = (int x, int y) => x + y;

final add4 = (int x, int y) => x + y;

// Sử dụng một hàm làm đối số của hàm

List<O> map<I, O>(
  List<I> entries,
  O Function(I) transform,
) {
  List<O> result = [];
  for (final v in entries) {
    result.add(transform(v));
  }
  return result;
}

map<int, int>([1, 2, 3, 4], (value) => value * 2);

// Một hàm trả về một hàm khác

Function grandParent(g1, g2) {
	// Closure
  final g3 = 3;
  return (p1, p2) {
    final p3 = 33;
    return (c1, c2) {
      final c3 = 333;
      return g1 + g2 + g3 + p1 + p2 + p3 + c1 + c2 + c3;
    };
  };
}
final parentFunc = grandParent(1, 2); // g1 = 1, g2 = 2, g3 = 3  
final childFunc = parentFunc(11, 22); // p1 = 11, p2 = 22, p3 = 33
print(childFunc(111, 222)); // c1 = 111, c2 = 222, p3 = 333
// 1 + 2 + 3 + 11 + 22 + 33 + 111 + 222 + 333 == 738

Closure

<aside> 💡 Một closure (bao đóng) là một scope của một hàm mà sẽ tồn tại chừng nào còn có tham chiếu đến hàm đó.

</aside>

First-class function

Đấy chúng ta vừa đi qua các vd của HOF tổng hợp hết lại chúng ta sẽ có được khái niệm mới First-class function. Đúng vậy như cái tên nó. Nơi function được coi là số 1 ✌️ Vậy nên để chơi FP cúng ta phải đến sân chơi có HOF và Lambda

  • Gán một hàm vào biến
  • Sử dụng một hàm làm đối số của hàm
  • Một hàm trả về một hàm khác

Vậy khi đến nơi function là số một thì chúng ta sẽ có lợi ích gì?

5. Currying

final numbers = [1,2,3];

final add = (a,b) => a + b;

final add1 = (a) => a + 1;

final incrementNumbers1 = numbers.map((value) => add(value,1));

final incrementNumbers2 = numbers.map((value) => add1(1));

print(incrementNumbers1); // 2 , 3 , 4
print(incrementNumbers2); // 2 , 3 , 4
numbers.map((value) => add1(1));
// có thể viết gọn lại
numbers.map(add1); // kết quả không thay đổi. hai cách viết tương đương nhau

Cách 2 loại bỏ khai báo param (tham số) chuyền vào hàm. Điều này ngắn gọn hơn rất nhiều và nó làm cho việc đọc lướt qua mã nhanh hơn. add1 hoạt động liền mạch vì nó là unary function (hàm chỉ có 1 param)

Vậy đối với hàm có nhiều params(n-array function)?

numbers.map((value) => add(1,1));
// Giờ chúng ta muốn viết theo cách này thì sao?
numbers.map(add(1));

Đây chính là lúc dùng cà ri (curry) 😄 . Hàm curry đợi cho đến khi chuyển hết tất cả tham số cần thiết thì nó mới thực thi. Nếu thiếu một tham số, nó sẽ trả về một hàm tạm thời khác tiếp tục chờ thay thế. Kỹ thuật này được gọi là "partial application” (Có nghĩa là apply một phần chứng năng)

// hàm tổng này tính tổng 2 số
final add = (a,b) => a + b;
// nó chỉ hoạt động khi có cả hai tham số a và b
// không thể viết rằng add(2) . nó sẽ lỗi :v

Bây giờ chúng ta sẽ ứng dụng cái hay ho của First-class function :v.

final add = (a) => (b) => a + b;
// Viết rõ ra cho mọi người dễ hiểu nó sẽ thế này
int Function(int) add(int x) {
  return (int y) {
    return x + y;
  };
}

final increase = add(1); // trả về một function tham số là y với x = 1

final four = increase(3); // 4
final five = increase(four); // 5

Vậy cứ mỗi lần viết hàm lại phải ⇒ khó hiểu quá , lỡ như có 4 pram mà cứ a ⇒ b ⇒ c ⇒ d . Ai hiểu nổi =))

Implement hàm curry luôn

Function curry2(Function fn) {
  return <A, B>([A? a, B? b]) {
    final args = [a, b].where((a) => a != null);
    switch (args.length) {
      case 2:
        return Function.apply(fn, List.of(args));
      case 1:
        return (b) => fn(args.toList()[0], b);
      default:
        throw TypeError();
    }
  };
}
// giờ nhìn ngon lành dễ hiểu
final add = curry2((a, b) => a + b);

final increase = add(1);

final four = increase(3);
final five = increase(four);

Tuy nhiên thì đây là đối với cộng trừ nhân chia phép trừ hay chia thì sao?

final subtract = (a) => (b) => a - b);

final subtract3 = subtract(3);
// giờ chúng muốn 5 - 3 thì nhưng kết quả sẽ bằng - 2
print(subtract3(5)); // -2 😢

Bởi vậy thứ tự trong curry rất quan trọng 😢

Để giải quyết thì làm sao?

  • Đổi vị trí lại(data-last function). tuy nhiên cách này củ chuối quá. 😛
  • Sử dụng kỹ thuật gọi là placeholder

Chúng ta làm lại hàm curry cho nó thông minh hơn

final _ = Symbol("placeholder").hashCode;

Function curry2(Function fn) {
    return ([a, b]) {
      final args = [a, b].where((a) => a != null).toList();
      switch (args.length) {
        case 2:
          if (args[0] != _) {
            return Function.apply(fn, List.of(args));
          }
          return (a) => fn(a, args[1]);
        case 1:
          return (b) => fn(args[0], b);
        default:
          throw TypeError();
      }
    };
  }

// usage
final subtract = curry2((a, b) => a - b);
final subtract3 = subtract(3);

 print(subtract3(5)); // 2 😙

6. Point-Free Notation

// traditional
final subtract = (a) => (b) => a - b);
final add = (a) => (b) => a + b;
// point-free
final subtract3 = subtract(3);
final add3 = add(3);

Viết theo style Point-free với tên biến có ý nghĩa có thể giúp đọc lướt qua một đoạn mã nhanh hơn. Nhưng khi bị lạm dụng, nó sẽ mất đi mục đích đó . =))

Đây là ví dụ mà tôi đọc code thấy dễ hiểu và sướng mắt ❤️‍🔥

final profiles = [
    {
      "name": "John",
      "age": 19,
      "address": {
        "country": "DN",
      },
    },
    {
      "name": "Michel",
      "age": 18,
      "address": {
        "country": "QN",
      },
    },
    {
      "name": "Cel",
      "age": 17,
      "address": {
        "country": 'ĐL',
      },
    },
  ];

  final bool Function(dynamic) isEligible = and([
    has('name'),
    has('age'),
    flow([
      select('age'),
      gte(18),
    ]),
  ]);

  final getCountriesOfEligibleProfiles = flow([
    filter(isEligible),
    map(select('address.country')),
    distinct,
  ]);

  final countries = getCountriesOfEligibleProfiles(profiles);

  print(countries); // [QN, ĐL]

Quá dễ hiểu đúng không? Tên function nói lên tất cả rồi :v . Tuy nhiên thì FP trong dart ít cộng động và thư viện quá. Nếu là js thì có lodash/fp , ramda. Dart thì có fpdart, fpfantasy,...

7. Functor and Monad

Functor

final profiles = [
    {
      "id": "1",
      "address": {
        "city": "DN",
      },
      "name": "Duy Nguyen",
    },
    {
      "id": "2",
      "name": "Minh D",
      "address": {
        "city": "DL",
      }
    }
  ];
get(obj,key) => obj[key];   

print(get(profiles.first, "name")); // Duy Nguyen
print(get(profiles.last, "name")); // Minh D

Vấn đề gặp ?

// OK
final name = get(profiles.firstWhere((obj) => obj["id"] === "1"), "name"); 
// name = Tung Vu

// OK
final name = get(profiles.firstWhere((obj) => obj["id"] === "3"), "name"); 
// Uncaught Error: Bad state: No element

Solution #1: check nullable

import 'package:collection/collection.dart';

final personNotNull =
      profiles.firstWhereOrNull((obj) => obj["id"] == "2");

final nameNotNull = get(personNotNull, "name");

final addressNotNull = get(personNotNull, "address");

print(nameNotNull); // Minh D
 
print(addressNotNull); // {city: DL}

Solution #2: Gói logic check null vào hàm get

final personNotNull =
      profiles.firstWhereOrNull((obj) => obj["id"] == "3");

get(obj, key) => obj == null ? null : obj[key];

// À thằng này chạy được vì mình check null personNotNull rồi 
get(personNotNull, "address"); // null

final toLowerCase = (str) => str.toLowerCase();

// Hàm này sẽ không chạy được vì không thể chạy null.toLowerCase()
toLowerCase(get(personNotNull, "name"));
// Uncaught TypeError: Cannot read properties of null (reading 'toLowerCase$0')Error: TypeError: Cannot read properties of null (reading 'toLowerCase$0')

Solution #2: functor type “Maybe”

Giờ thay vì chúng ta xử lý với dử liệu thô thì sẽ bọc nó trong một cái hộp

(sưu tầm: adit.io)

Chúng ta sẽ dùng cá hộp thật trong code [] :

findMaybe(array, id) {
    final value = array.firstWhereOrNull((obj) => obj["id"] == id);
    return value == null ? [] : [value]; // cũng không khác gì mấy =))
  }

// tuy nhiên
findMaybe(profiles, "1").map((p) => get(p, "name")); // [Duy Nguyen]

findMaybe(profiles, "3").map((p) => get(p, "name")); // []

Giờ chúng ta sẽ tránh được các trường hợp check null , và thêm nữa là sẽ có thể nói các chuỗi transform lại với nhau


findMaybe(profiles, "2")
	.map((p) => get(p, "address")) // [{city:DL}]
	.map((a) => get(a, "city")) // [DL]
	.map(toLowerCase) // [dl]

findMaybe(profiles, "3")
	.map((p) => get(p, "address")) // [] trả về cái hộp rỗng =))
	.map((a) => get(a, "city"))
	.map(toLowerCase)

Để truy cập giá trị được bao bọc bên trong một hộp, chỉ cần gọi findMaybe[0]

Đó kiểu wrap một giá trị đơn giản với một logic cách hoạt động trên giá trị đó (context) là một kiểu Functor. Nghe quen rồi đúng không? đây là thứ khi xây codebase thường dùng nhất =)) wrap exception, bla bla.

Monad

Nó giống với Functor tuy nhiên với một số trường hợp thì nó lại hơn khác. Nếu hiểu Functor thì việc hiểu monad cũng dễ dàng hơn.

profiles.push({
	id: "3",
	name: 'Monad'
	// không thêm địa chỉ
})

// Lỗi :(
findMaybe(profiles, "3")
	.map((p) => get(p, "address"))
	.map((a) => get(a, "city"))
	.map(toLowerCase)
	// Unhandled exception:
	// NoSuchMethodError: The method 'toLowerCase' was called on null.

Lý do:

get(obj, key) => obj == null ? null : obj[key];

findMaybe(profiles, "3")
	.map((p) => get(p, "address")) // [null]
	.map((a) => get(a, "city")) // [null]

Giờ mình sẽ chuyển nó thành cái hộp rỗng thay vì null [[]] nên cần thay đổi hàm get

get(obj, key) => obj != null && obj.containsKey(key) ? [obj[key]] : []

findMaybe(profiles, "2")
	.expand((p) => get(p, "address")) // [{city:DL}]
	.expand(a => get(a, "city")) // [DL]
	.map(toLowerCase) // [dl]

findMaybe(profiles, "3")
	.expand((p) => get(p, "address")) // []
	.expand((a) => get(a, "city")) // []
	.map(toLowerCase) // []

Monad đưa cách tiếp cận của Functor lên cấp độ siêu saiyan 2, nó cho phép dữ liệu transform trả về một cái hộp hoặc dư liệu thô.

Triển khai thực tế

class Just<T> {
  final T _value;
  Just(this._value);
  Just<E> map<E>(f) => Just(f(_value));
  Just<E> flatMap<E>(f) => f(_value);
  unwrap() => _value;
}

class Nothing {
  Nothing map(f) => Nothing();
  Nothing flatMap(f) => Nothing();
  unwrap() => null;
}

findMaybe(List array, id) {
    final value = array.firstWhereOrNull((obj) => obj["id"] == id);
    return value == null ? Nothing() : Just(value);
  }

get(obj, key) => obj != null && obj.containsKey(key) ? Just(obj[key]) : Nothing();

final toLowerCase = (str) => str.toLowerCase();

findMaybe(profile, "1")
	.flatMap((p) => get(p, "address"))
	.flatMap((a) => get(a, "city"))
	.map(toLowerCase)
	.unwrap(); // dn

findMaybe(profile, "3")
	.flatMap((p) => get(p, "address"))
	.flatMap((a) => get(a, "city"))
	.map(toLowerCase)
	.unwrap(); // null 

Vậy một kiểu dự liệu vừa có mapflatMap thì nó vừa là một functormonad

Future#then(...)

Trong dart future cũng có cả hai chức năng tuy nhiên nó chỉ gói gọn trong một hàm tên à .then()

fetch(url)                        // --> a box wraps around a future value
  .then(resp => resp.json())      // --> this `then` acts like flatMap
  .then(data => data.someValue);   // --> this `then` acts like map

⇒ Rx hay coroutines cũng vậy ^^

Kết hợp với curry, point-free

final lowCaseCityWithId = findMaybe(profile)
	.flatMap(get("address"))
	.flatMap(get("city"))
	.map(toLowerCase);

lowCaseCityWithId(3).unwrap(); // null

// mượt như ngọc trinh =))

Vậy là chúng ta đã đi qua hầu như tất cả các những thì cần phải biết của FP rồi ^^. Hãy nói cảm nghỉ của bạn cho tôi xem nào ?