Haskell: một ngôn ngữ thủ tục xuất sắc
(entropicthoughts.com)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 Intbiể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
doblock 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 độngIO ()duy nhất kết hợp hai lần xuất theo đúng thứ tự - Khi viết nhiều dòng trong
doblock, 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_sidetạo ra một tác dụng phụ truyền kết quả tung xúc xắc choprint_sideđể in ra - Mẫu
<-trongdoblock 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,
doblock đượ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 xtạ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 4tạo ra mộtIO Intluô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ụnglengthđể tính độ dài
liftA2, liftA3, …
- Các hàm như
liftA2,liftA3kế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
<?>và<*>
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
logvào một danh sách rồi thực thi một lượt bằngsequenceAsau đó - 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 nphần cần thiết và thực thi bằngsequenceA
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
sequenceAchính làtraverse id, còntraverse_là phiên bản bỏ qua kết quả
for
-
forcó chức năng giốngtraversenhư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
Statethay choIOđể 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
evalStatev.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
fmapcó 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
fmapcho danh sách, nó áp hàm lên mọi phần tử; khi áp dụng choIO, 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
Foldablelà cấu trúc có thể duyệt qua các phần tử để xử lýTraversablelà 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- Để
sequenceAhaytraversecó 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
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ẽ..
Ý 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>>=và>>để duy trì năng suấtHaskell giúp cải thiện cách lập trình mệnh lệnh
Phiên bản tổng quát hóa của
traverse/mapMrất hữu ích vì nó hoạt động không chỉ với list mà với mọi kiểuTraversabletraverse :: Applicative f => (a -> f b) -> t a -> f (t b)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
doMộ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à 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 1còn<i>>được định nghĩa làinfixl 4, nên<i>>kết hợp chặt hơn>>IO avàatrong Haskell có thể tạo cảm giác tương tự bất đồng bộ và đồng bộ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")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