3 điểm bởi GN⁺ 2025-01-20 | 2 bình luận | Chia sẻ qua WhatsApp

Xử lý side effect như giá trị hạng nhất

  • Trong Haskell, các tác dụng phụ (ví dụ: sinh số ngẫu nhiên, xuất dữ liệu, v.v.) được xem như “giá trị hạng nhất (first class value)”
  • Nói cách khác, chính lời gọi hàm tạo ra tác dụng phụ như randomRIO(1, 6) không trả về kết quả cuối cùng, mà trả về một “đối tượng mô tả hành động sẽ được thực thi vào một thời điểm nào đó”
  • Đối tượng này sẽ tạo ra giá trị ngẫu nhiên khi thực sự được chạy, nhưng trước đó nó chỉ chứa kế hoạch thực thi
  • Kiểu như IO Int biểu thị “một hành động khi được thực thi sẽ tạo ra Int”, và nó không chạy ngay tại thời điểm gọi mà sẽ được thực thi vào lúc cần thiết sau đó
  • Nhờ đặc tính này, khác với các ngôn ngữ thủ tục truyền thống nơi “gọi hàm = thực thi ngay”, trong Haskell có thể kết hợp các tác dụng phụ rồi thực thi chúng sau

Giải nghĩa do block

  • do block không phải cú pháp thần bí, mà thực chất được cấu thành từ hai phép toán: nối (bind) tác dụng phụ và thực thi tuần tự (then)

then

  • Toán tử *> thực thi tác dụng phụ bên trái rồi bỏ qua giá trị kết quả, sau đó tiếp tục thực thi tác dụng phụ bên phải
  • Ví dụ putStr "hello" *> putStrLn "world" tạo ra một hành động IO () duy nhất kết hợp hai lần xuất theo đúng thứ tự
  • Khi viết nhiều dòng trong do block, bên trong Haskell dùng kiểu phép toán thực thi tuần tự này

bind

  • Toán tử >>= có vai trò thực thi tác dụng phụ bên trái rồi truyền giá trị thu được cho hàm ở bên phải
  • Ví dụ: randomRIO(1, 6) >>= print_side tạo ra một tác dụng phụ truyền kết quả tung xúc xắc cho print_side để in ra
  • Mẫu <- trong do block là cách biểu diễn thuận tiện cho phép toán này

Hai toán tử là toàn bộ do block

  • Rốt cuộc, do block được xây dựng từ đúng hai toán tử *>, >>=
  • Cú pháp do được dùng nhiều vì dễ đọc và tiện lợi, nhưng để khai thác tốt hơn ưu điểm của Haskell, cần tận dụng các hàm kết hợp tác dụng phụ phong phú hơn

Các hàm thao tác trên side effect

  • Thư viện chuẩn có nhiều hàm để xử lý tác dụng phụ theo cách đa dạng hơn

pure

  • pure x tạo ra “một hành động cho kết quả là giá trị x mà không có thêm tác dụng phụ nào”
  • Ví dụ: loaded_die = pure 4 tạo ra một IO Int luôn trả về 4

fmap

  • Với dạng fmap :: (a -> b) -> IO a -> IO b, nó tạo ra một hành động mới bằng cách áp dụng hàm thuần lên giá trị kết quả của tác dụng phụ
  • Ví dụ: length <$> getEnv "HOME" có thể tạo ra một hành động lấy biến môi trường rồi áp dụng length để tính độ dài

liftA2, liftA3, …

  • Các hàm như liftA2, liftA3 kết hợp kết quả từ nhiều tác dụng phụ bằng một hàm thuần để tạo ra tác dụng phụ mới
  • Ví dụ: liftA2 (+) (randomRIO(1,6)) (randomRIO(1,6)) tạo ra một tác dụng phụ cộng tổng hai lần tung xúc xắc
  • Có thể làm điều tương tự bằng cách kết hợp <?><*>

Tạm dừng: ý nghĩa là gì?

  • Cách làm này có thể trông như một tính năng đơn giản mà ngôn ngữ khác cũng làm được, nhưng trong Haskell có ưu điểm là dù tách một hành động tác dụng phụ ra biến hay tái tổ hợp nó ở đâu, thời điểm thực thi và kết quả cũng không thay đổi
  • Nhờ xử lý tác dụng phụ một cách độc lập, việc refactor code ít gây nhầm lẫn hơn và cho phép tái sử dụng an toàn dựa trên suy luận đẳng thức (equational reasoning)

sequenceA

  • sequenceA [IO a] -> IO [a] biến “một danh sách các hành động tác dụng phụ” thành “một hành động tác dụng phụ duy nhất trả về danh sách kết quả”
  • Ví dụ: có thể gom nhiều hành động log vào một danh sách rồi thực thi một lượt bằng sequenceA sau đó
  • Ngay cả tác dụng phụ lặp vô hạn (ví dụ: repeat (randomRIO(1,6))) cũng có thể lưu thành danh sách, rồi chỉ take n phần cần thiết và thực thi bằng sequenceA

Xen đoạn: các hàm tiện ích

  • void, sequenceA_, replicateM, replicateM_ v.v. hữu ích khi không dùng giá trị kết quả hoặc cần lặp thực thi
  • Ví dụ: replicateM_ 500 (putStrLn "I will not cheat again.") cho phép chạy tác dụng phụ nhiều lần mà không phải tự đếm số vòng lặp

traverse

  • traverse :: (a -> IO b) -> [a] -> IO [b] tạo ra một hành động áp dụng hàm có tác dụng phụ lên từng phần tử của danh sách rồi gom kết quả lại thành danh sách
  • Thực ra sequenceA chính là traverse id, còn traverse_ là phiên bản bỏ qua kết quả

for

  • for có chức năng giống traverse nhưng nhận đối số theo thứ tự ngược lại

  • Ví dụ: dạng for numbers $ \n -> ... cho phép biểu đạt tự nhiên như cú pháp “for loop”

  • Nhờ các tổ hợp này, những việc ở ngôn ngữ khác cần cú pháp riêng như lặp, duyệt, chuyển đổi cấu trúc dữ liệu có thể được triển khai trong Haskell bằng tổ hợp hàm thư viện

Tận dụng tính hạng nhất của effect

  • Nếu chủ động tận dụng việc tác dụng phụ là giá trị hạng nhất trong Haskell, có thể giảm trùng lặp code và cải thiện cấu trúc chương trình
  • Ví dụ, trong logic phân tích thừa số nguyên tố của số lớn có dùng bộ nhớ đệm, có thể dùng State thay cho IO để tạo cấu trúc “có tác dụng phụ nhưng không ảnh hưởng ra bên ngoài”
  • Các tác dụng phụ được cấu trúc theo cách này chỉ áp dụng ở phần cần thiết, còn phần còn lại của code vẫn có thể giữ là hàm thuần, từ đó đảm bảo cả tính an toàn lẫn linh hoạt
  • Cuối cùng có thể dùng evalState v.v. để thực thi tác dụng phụ thực sự và biến kết quả thành giá trị thuần

Những điều bạn không bao giờ cần bận tâm

  • Nhiều cái tên tồn tại từ thời Haskell cũ (>>, return, mapM, v.v.) hiện có thể thay bằng các hàm hiện hành như *>, pure, traverse
  • Chúng bắt nguồn từ “tên gọi cũ hoặc thiết kế lấy monad làm trung tâm”, còn ngày nay người ta khuyến nghị cách tiếp cận dựa trên Applicative hay Functor tổng quát hơn

Phụ lục A: Tránh thành công và sự vô dụng

  • Câu “Haskell tránh thành công” có nghĩa là “ngôn ngữ này không hy sinh giá trị cốt lõi chỉ vì độ phổ biến hay sự tiện dụng”
  • “Haskell is useless” phản ánh bối cảnh ban đầu khi ngôn ngữ chỉ cho phép hàm thuần hoàn toàn nên trông như chẳng làm được gì, nhưng sau đó đã đạt được tính thực dụng nhờ đưa vào cách xử lý tác dụng phụ như ‘hạng nhất’

Phụ lục B: Vì sao fmap ánh xạ được cả side effect lẫn danh sách

  • fmap có dạng rất tổng quát (Functor f => (a -> b) -> f a -> f b), nên có thể áp dụng thống nhất cho nhiều kiểu container hoặc kiểu tác dụng phụ như list, Maybe, IO
  • Khi áp dụng fmap cho danh sách, nó áp hàm lên mọi phần tử; khi áp dụng cho IO, nó áp hàm lên giá trị kết quả
  • Theo cách này, mọi “cấu trúc có thể áp dụng hàm lên bên trong” đều được gọi là Functor

Phụ lục C: Foldable và Traversable

  • Foldable là cấu trúc có thể duyệt qua các phần tử để xử lý
  • Traversable là cấu trúc không chỉ cho phép duyệt mà còn có thể tái tạo lại cùng hình dạng cấu trúc với các phần tử mới
  • Để sequenceA hay traverse có thể gom giá trị mà vẫn giữ nguyên cấu trúc ban đầu, cấu trúc đó phải là Traversable
  • Những cấu trúc dữ liệu như cây hoặc Set có thể thay đổi hình dạng theo giá trị, nên cần phân biệt trường hợp chỉ duyệt được (Foldable) và trường hợp có thể tái tạo cấu trúc thật sự (Traversable)
  • Tùy nhu cầu, cũng có thể chuyển sang list trước rồi dùng traverse để xử lý tác dụng phụ linh hoạt hơn

2 bình luận

 
bbulbum 2025-01-21

Lướt Reddit thì thấy quảng cáo xuất hiện khá nhiều.. Nhưng ngay từ cái tên đã tạo cảm giác hơi có rào cản tâm lý.
Cứ thấy như đây là một ngôn ngữ rất khó và mạnh mẽ..

 
GN⁺ 2025-01-20
Ý kiến trên Hacker News
  • Hệ thống kiểu của Haskell có độ phức tạp nhất định khi so với các ngôn ngữ phổ biến khác. Đặc biệt, các toán tử như *>, <*>, <* làm tăng độ dốc học tập trên toàn bộ codebase

    • Nếu không dùng Haskell trong một tháng, bạn có thể phải học lại các toán tử như >>=>> để duy trì năng suất
    • Nếu tự học các khái niệm của Haskell mà không trao đổi với người khác, việc này sẽ khá khó khăn
  • Haskell giúp cải thiện cách lập trình mệnh lệnh

    • Có thể loại bỏ mã boilerplate bằng cách dùng hiệu ứng hạng nhất và pattern
    • Nhờ tính an toàn kiểu, có thể nhanh chóng viết ra mã tương đối ít lỗi
  • Phiên bản tổng quát hóa của traverse/mapM rất hữu ích vì nó hoạt động không chỉ với list mà với mọi kiểu Traversable

    • Có thể dùng dưới dạng traverse :: Applicative f => (a -> f b) -> t a -> f (t b)
    • Ở các ngôn ngữ khác, để đạt hiệu quả tương tự thường phải tự viết rất nhiều mã thủ công
  • Haskell có các monad mạnh mẽ, điều này khiến Haskell trở nên mang tính thủ tục hơn

    • Có thể dùng biến trung gian trong khối do
  • Một phần mềm được viết bằng Haskell là ImplicitCAD

  • Mã Haskell có thể được đọc giống như ngôn ngữ thủ tục, nhưng vẫn mang lại lợi thế khi làm việc với các hàm có tác dụng phụ

    • Làm việc với IO monad là phức tạp, và còn phức tạp hơn khi muốn dùng các kiểu monad khác
  • >> là tên cũ của <i>>, và cả hai toán tử đều là toán tử kết hợp trái

    • >> được định nghĩa là infixl 1 còn <i>> được định nghĩa là infixl 4, nên <i>> kết hợp chặt hơn >>
  • IO aa trong Haskell có thể tạo cảm giác tương tự bất đồng bộ và đồng bộ

    • Loại đầu tiên trả về một promise/future mà phải chờ
  • Trong các ngôn ngữ khác, có thể thực hiện IO đơn giản bằng hàm như console.log("abc")

    • Có thắc mắc liệu điều này có gì khác với IO trong Haskell hay không
  • Những người chưa từng thử Haskell có thể cảm thấy Haskell thực tế với các phần mở rộng GHC quá phức tạp

    • Điều này có thể làm giảm hứng thú đối với Haskell