Vì sao cần Algebraic Effects
(antelang.org)- Algebraic effects (effect handlers) là công cụ điều khiển luồng linh hoạt cho phép hiện thực nhiều tính năng ngôn ngữ khác nhau (xử lý ngoại lệ, generator, coroutine, v.v.) ở cấp thư viện
- Cũng có thể áp dụng cho các bài toán thường gặp trong lập trình hàm như quản lý ngữ cảnh, dependency injection, thay thế trạng thái toàn cục, v.v.
- Góp phần vào thiết kế API gọn gàng và tự động hóa việc truyền trạng thái/môi trường trong mã
- Còn có ưu điểm là hỗ trợ đảm bảo tính thuần hàm, khả năng replay, kiểm toán bảo mật
- Nhờ những tiến bộ gần đây trong công nghệ trình biên dịch, các vấn đề hiệu năng cũng đã được cải thiện đáng kể
Tổng quan về Algebraic Effects
Algebraic effects (còn gọi là effect handlers) là một tính năng ngôn ngữ lập trình đang được chú ý trong thời gian gần đây. Đây là một trong những tính năng cốt lõi của Ante và nhiều ngôn ngữ nghiên cứu khác (Koka, Effekt, Eff, Flix, v.v.), và đang lan rộng rất nhanh. Nhiều tài liệu giải thích khái niệm effect handlers, nhưng phần giải thích chuyên sâu về việc chúng thực sự cần thiết "vì sao" vẫn còn thiếu. Bài viết này giới thiệu càng rộng càng tốt các trường hợp sử dụng thực tế và lợi ích của algebraic effects.
Hiểu nhanh về cú pháp và ngữ nghĩa
- Algebraic effects là một khái niệm tương tự như "ngoại lệ có thể tiếp tục lại"
- Có thể khai báo hàm effect như
effect SayMessage - Có thể chỉ rõ khả năng sử dụng effect trong hàm như
foo () can SayMessage = ... - Có thể xử lý bằng
handle foo () | say_message () -> ..., tương tựtry/catchcủa ngoại lệ
Thông qua cấu trúc cơ bản như vậy, ta có thể gọi và điều khiển effect.
Mở rộng luồng điều khiển do người dùng định nghĩa
Lý do lớn nhất để dùng algebraic effects là chỉ với một tính năng ngôn ngữ, có thể hiện thực bằng thư viện những khả năng vốn trước đây cần đến các tính năng ngôn ngữ riêng biệt như generator, ngoại lệ, coroutine, bất đồng bộ, v.v.
- Nếu đặt biến effect đa hình (
can e) cho hàm, có thể truyền và kết hợp nhiều effect khác nhau như tham số hàm - Ví dụ, hàm
mapcó thể được khai báo sao cho hàm truyền vào được phép dùng một effect tùy ýe, từ đó kết hợp tự nhiên với nhiều hiệu ứng khác nhau (xuất ra, bất đồng bộ, v.v.)
Ví dụ hiện thực ngoại lệ và generator
- Hiện thực ngoại lệ: nếu phát sinh effect rồi xử lý mà không gọi
resumethì sẽ hoạt động giống hệt ngoại lệ - Hiện thực generator: định nghĩa effect
Yield; mỗi khi yield một giá trị, handler bên ngoài có thể can thiệp để điều khiển luồng theo điều kiện, và cả các mẫu nâng cao như filtering cũng có thể viết bằng mã ở mức đơn giản
Khả năng kết hợp nhiều effect với nhau cũng là một ưu điểm lớn so với các kỹ thuật trừu tượng hóa hiệu ứng trước đây.
Sử dụng như một tầng trừu tượng hóa
Algebraic effects không chỉ mở rộng các tính năng lập trình cốt lõi mà còn rất hữu ích trong nhiều kịch bản ứng dụng nghiệp vụ khác nhau.
Dependency Injection
- Có thể trừu tượng hóa các đối tượng phụ thuộc như cơ sở dữ liệu, đầu ra, v.v. thành effect để quản lý bằng handler
- Cũng có thể linh hoạt hiện thực việc thay thế bằng mock cho kiểm thử, chuyển hướng đầu ra, v.v.
Ghi log có điều kiện hoặc quản lý đầu ra
- Có thể điều khiển tập trung việc có in thông điệp log hay không tùy theo mức log
Đơn giản hóa thiết kế API và tự động hóa truyền Context
Sử dụng state effect
- Trong các tình huống cần truyền đối tượng Context hoặc thông tin môi trường, nếu hiện thực theo hướng effect chỉ dùng
get/set, có thể tự động hóa quản lý trạng thái mà không cần truyền tường minh - Trước đây phải truyền context làm tham số cho mọi hàm, nhưng với state effect có thể ẩn phần này đi
Thay thế đối tượng toàn cục
- Những trạng thái vốn được quản lý bằng đối tượng toàn cục như bộ sinh số ngẫu nhiên, cấp phát bộ nhớ, v.v. cũng có thể được trừu tượng hóa thành effect, có lợi về độ rõ ràng của mã, tính dễ kiểm thử và hỗ trợ đồng thời
- Chỉ cần thay handler là có thể linh hoạt đổi nguồn số ngẫu nhiên thực tế
Hỗ trợ viết theo direct style
- Trước đây thường phải lồng nhiều đối tượng như option type, error wrapping, v.v.
- Effect cho phép biểu đạt sạch sẽ các đường đi lỗi hay tác dụng phụ mà không cần các lớp wrapping như vậy
Đảm bảo tính thuần và kiểm toán bảo mật
Tường minh hóa tác dụng phụ
- Trong hầu hết các ngôn ngữ có effect handlers, các hàm gây tác dụng phụ bắt buộc phải khai báo effect như
can IO,can Print, v.v. trong chữ ký kiểu - Những trường hợp như tạo thread, software transactional memory (STM), v.v. thì bắt buộc cần hàm thuần
Replay log và networking xác định
- Dựa trên tính thuần, có thể tạo các handler như
record,replayđể tái hiện lại kết quả thực thi - Có thể hỗ trợ kết quả mang tính xác định và rollback cho debugging, cơ sở dữ liệu, mạng game, v.v.
Hỗ trợ Capability-based Security
- Mọi effect chưa được xử lý đều lộ ra trong chữ ký kiểu của hàm, nên rất hiệu quả khi kiểm toán bảo mật thư viện bên ngoài
- Nếu một hàm trước đây không có tác dụng phụ nhưng sau khi cập nhật lại gắn thêm
can IO, thì mã gọi hàm đó có thể phát hiện ngay lập tức
Tuy vậy, vì mọi effect đều tự động lan truyền nên cũng có thể phát sinh tác dụng phụ là effect bị xử lý một cách vô thức.
Góc nhìn hiệu quả và kết luận
- Trước đây hiệu quả thực thi là điểm yếu, nhưng gần đây trong nhiều trường hợp như tail-resumptive effects, các tối ưu hóa đã tiến bộ rất nhiều
- Mỗi ngôn ngữ cũng áp dụng các chiến lược biên dịch hiệu quả riêng (closure call, evidence passing, chuyên biệt hóa handler, v.v.)
Algebraic effects được kỳ vọng sẽ chiếm một vị trí ngày càng cốt lõi hơn trong các ngôn ngữ lập trình của tương lai.
1 bình luận
Ý kiến trên Hacker News
Tôi thấy có hai nhược điểm
Thứ nhất là khi nhìn vào đoạn mã được đưa ra, hoàn toàn không có dấu hiệu nào cho thấy
foohaybarcó thể thất bạiMuốn biết các lời gọi như vậy có thể kích hoạt trình xử lý lỗi hay không thì phải tự đi tra cứu type signature, và tùy tình huống có thể phải làm thủ công nếu không có sự trợ giúp của IDE
Thứ hai là sau khi đã biết
foovàbarcó thể thất bại, nếu muốn tìm đoạn mã nào sẽ chạy khi thực sự thất bại thì phải lần ngược khá xa lên call stack để tìm biểu thứcwith, rồi từ đó lại lần xuống theo handler tương ứngKhông thể lần theo hành vi này một cách tĩnh hay nhảy thẳng đến định nghĩa trong IDE, vì
my_functioncó thể được gọi ở nhiều nơi với nhiều handler khác nhauTôi nghĩ khái niệm này rất mới mẻ, nhưng rốt cuộc vẫn có lo ngại về mặt khả năng đọc mã và debug
Về vấn đề tìm đoạn mã nào sẽ chạy khi thực thi thất bại, có người giải thích rằng đó chính là cốt lõi của dynamic code injection
Cấu trúc này, giống như shallow-binding, deep-binding và các tính năng động khác, thực hiện việc binding theo call stack
Việc không thể phân tích tĩnh hay nhảy bằng IDE cũng là do tính động đó
Tuy nhiên tôi cho rằng trên thực tế không cần quá bận tâm đến chuyện này
Bởi vì đây là cách thêm effect vào mã thuần, nên tùy ngữ cảnh nó có thể được nối vào mock dùng cho kiểm thử, môi trường production, hoặc effect thuần lẫn không thuần
Nguyên lý này khá giống dependency injection
Trong monad truyền thống cũng có thể triển khai tương tự, nhưng để tìm chỗ monad thực sự được khởi tạo thì rốt cuộc vẫn phải xem call stack
Những kỹ thuật này có lợi ích riêng, nhưng đồng thời cũng rõ ràng có cái giá phải trả
Chúng thuận lợi cho kiểm thử và sandboxing, nhưng lại không làm cho những gì đang diễn ra trong mã trở nên hiển nhiên
Có người chia sẻ từng viết luận văn cử nhân về hỗ trợ IDE cho lexical effects và handlers
Họ cho rằng tất cả các điểm được nêu ở trên đều hoàn toàn khả thi
Liên kết luận văn
Có ý kiến nói rằng trong hệ sinh thái .NET có xu hướng lạm dụng interface, khiến việc nhảy thẳng tới phần cài đặt của method trở nên phiền phức vì phải qua nhiều bước
Nhiều khi nếu implementation nằm ở assembly khác thì tính năng IDE gần như vô dụng
Trong các hệ thống Dependency Injection nâng cao, tiêu biểu là Autofac, người ta xây scope theo dạng phân cấp giống biến dynamic scope trong LISP để quyết định lúc runtime một service sẽ được bind vào instance nào
Ở khía cạnh này, effect có thể được đưa vào dưới dạng instance của một interface như
ISomeEffectHandler, và khi effect phát sinh thì được biểu diễn bằng lời gọi method tương ứngHành vi cụ thể của handler, như ném exception hay ghi log, sẽ được quyết định động theo cấu hình DI
Trước đây thường dùng mẫu
throwexception, nhưng cũng có thể chuyển sang thiết kế khai báo effect dựa trên interface và giao toàn bộ cách xử lý cho DICòn những phần liên quan đến iterator như
yieldthì chưa đào sâuCó người cho rằng điểm cốt lõi chính là việc không có dấu hiệu cho thấy
foovàbarcó thể thất bạiNhờ vậy có thể viết mã theo direct style mà không phải bận tâm đến ngữ cảnh effect
Việc truy tìm đoạn mã nào sẽ chạy khi thất bại cũng chính là bản chất của abstraction
Handler effect thực tế nào sẽ được gắn vào lúc chạy là chuyện được quyết định về sau
Nguyên lý này cũng giống như với
f : g:(A -> B) -> t(A) -> B, ta không thể biết trước chính xác đoạn mã nào sẽ chạy khigđược thực thiCó người không đồng ý với lập luận rằng việc lần ngược call stack để tìm handler là không thể phân tích tĩnh
Theo họ, trên thực tế việc phân tích tĩnh là khả thi, và trong IDE có thể dùng những tính năng như "go to callers" để chọn xem handler nào đang được dùng
"pseudocode" của Ante rất ấn tượng
Nó mang lại cảm giác như sự kết hợp khéo léo giữa đặc tính của Haskell với tính biểu đạt và tính thực dụng của Elixir
Cảm giác như Haskell dành cho lập trình viên
Mong compiler sớm trưởng thành hơn
Hy vọng có ngày được thử phát triển ứng dụng bằng Ante
Về nhận định rằng AE (Algebraic Effects) khái quát hóa control flow nên cũng có thể triển khai coroutine
Có người cho rằng cách đơn giản nhất để triển khai AE trong một runtime ngôn ngữ mới thực ra là tận dụng coroutine rồi phủ cú pháp effect lên cấu trúc
yield/resumecơ bảnHọ hỏi liệu mình có đang bỏ sót điểm gì không
Một khác biệt tiêu biểu giữa AE và coroutine là type safety
Trong AE, có thể khai báo rõ trong mã nguồn rằng một hàm được phép dùng những effect nào
Ví dụ, nếu có dạng
query_db(): User can Databasethì điều đó nghĩa là hàm có thể truy cập cơ sở dữ liệu, và khi gọi bắt buộc phải cung cấpDatabasehandlerCác ràng buộc về việc cái gì được phép làm và cái gì không được phép làm được thể hiện rất rõ
Giống như trong NextJS, server component không thể trực tiếp dùng tính năng phía client, kiểu ràng buộc an toàn như vậy đang được ưa chuộng ở nhiều lĩnh vực
Effect-TS trong JavaScript khá gần với cách tiếp cận này là tận dụng coroutine, nhưng rốt cuộc người ta cũng không chắc đó có phải ý tưởng hay hay không
Tương tự DI trong framework Spring, có lo ngại rằng AE có thể lan ra khắp toàn bộ codebase và rốt cuộc chỉ làm tăng độ phức tạp
Thực tế, nhiều bài nói tại EffectDays giới thiệu cách dùng effect ở frontend bị chỉ trích là hầu như chỉ toàn boilerplate vô nghĩa
AE là một khái niệm rất hấp dẫn, nhưng gánh nặng phải bọc nhiều thứ vào hàm có thể làm giảm sự gọn nhẹ vốn là điểm mạnh của JavaScript
Trong khi đó, những cách tiếp cận chỉ dùng coroutine như motioncanvas lại có ưu thế lớn ở khả năng biểu đạt dễ dàng các kịch bản đồ họa 2D phức tạp
Video liên quan EffectDays
MotionCanvas
Có ý kiến nói rằng trong cùng một thread, AE handler có thể
resumemã nhiều lần giốngcall/ccNgược lại, với coroutine thì mỗi lần
yieldchỉ có thể được tiếp tục một lầnLoại luồng thực thi khó đoán này lại càng làm việc suy luận trở nên khó khăn hơn, nên có người thích cách trả về tường minh các hàm có thể được gọi nhiều lần, hoặc thay bằng iterator hay cấu trúc khác
Có quan điểm cho rằng với tư cách là một abstraction cho lập trình, khái niệm này cực kỳ hấp dẫn
Khi từng làm lập trình kernel ở Sun, người đó thấy một lợi thế rất lớn là có thể viết mã ngắn gọn với các lời gọi như
sleep(foo)rồi đượcfoođánh thức lại sau đóGánh nặng phải tự xử lý từng edge case bằng control flow giảm đi đáng kể
Chỉ cần chú ý đến các vấn đề liên quan đến memory locality, thì việc khởi tạo sẵn nhiều hàm ở trạng thái chờ và biểu diễn trực tiếp thuật toán như sự biến đổi của từng đơn vị nghe có vẻ rất thú vị
Về nhận định "algebraic effects giống như exception có thể resume"
Có người hỏi rốt cuộc nó khác gì về thực chất so với các type class
ApplicativeErrorhayMonadErrorCách khai báo các effect mà một hàm có thể dùng thì khá giống checked exceptions, còn việc xử lý effect bằng biểu thức
handlecũng gần như tương tựtry/catchCác type class này vốn đã hỗ trợ bắt exception bằng những cách như
handleError/handleErrorWithNgười ta nói algebraic effects có lợi thế để áp dụng cho ngôn ngữ của "tương lai", nhưng thực tế đây lại là thứ đã được dùng khá đầy đủ ngay từ hôm nay
Giải thích của cats
Nếu chỉ xử lý một effect đơn lẻ thì có thể không khác biệt nhiều, nhưng khi cần nhiều effect cùng lúc thì hỗ trợ effect trực tiếp rõ ràng gọn gàng và trực quan hơn nhiều so với cách lồng monad một cách tường minh
Khi kết hợp monad, ta dễ gặp những vấn đề đau đầu như thứ tự, hoặc phải thay đổi thứ tự khi kết quả của một số hàm không khớp với tập monad mà nơi khác đang mong đợi
Có người cho rằng cá nhân họ nhìn monad và effect không phải như hai thứ cạnh tranh, mà đúng hơn là hai cách diễn giải bổ sung cho nhau
Có thể tham khảo các bài báo liên quan như bài báo về Koka
Algebraic effects hoạt động trên stack của chương trình giống như delimited continuation
Chỉ với vài mẹo monad đơn giản thì không thể ngay lập tức nhảy đến effect handler nằm ở stack frame thứ 5 phía trên, chỉnh sửa biến cục bộ trong frame đó, rồi lại quay về frame thứ 5 phía dưới
Khác biệt nằm ở hành vi tĩnh so với động
Khi lập trình bằng monad, bạn phải tự cài đặt mọi method liên quan, còn trong effect system thì có thể cài đặt effect handler một cách động tại bất kỳ thời điểm nào và linh hoạt override handler sẵn có
Ví dụ, cũng có thể tạo cấu trúc phức hợp kiểu dùng một monad chuyên biệt mang tính chất IO cho mục đích test ở tầng dưới, rồi chỉ cài effect handler ở phần bên dưới đó
Tuy có nhiều điểm tương đồng, nhưng khác biệt về khả năng sử dụng là đáng kể
Algebraic effects có cấu trúc gần với monad
free, nhưng vì được tích hợp sẵn nên cú pháp dễ hơn và composability cũng tốt hơnTrong các ngôn ngữ thiên về monad như Haskell, nhờ suy luận type class theo kiểu
mtlvà cú phápbindtích hợp sẵn, đôi khi cũng có thể tạo cảm giác khá giốngBan đầu có người hiểu lầm rằng algebraic effects chỉ được bàn tới trong static type system, nhưng gần đây mới biết còn có cả các cấu trúc động khác
Hai bài viết cũ về phiên bản động của Eff, bài thứ nhất, bài thứ hai, để lại ấn tượng đặc biệt
Những khái niệm như "generalized parameterized operations with arbitrary arity" cũng khiến họ thấy thú vị khi kết nối abstraction với lập trình
Có người nhắc rằng đây vốn là một khái niệm cũ đang quay trở lại với tên gọi và khuôn khổ mới
Giới thiệu LISP Condition System
Trải nghiệm Algebraic Effects
Có người từng dùng effects trong bản alpha của OCaml 5 để làm protohackers
Dù khá vui nhưng toolchain khi đó hơi bất tiện
Ante đem lại cảm giác tương tự nên họ kỳ vọng vào sự phát triển sắp tới
Dù vẫn chưa có type system đi kèm, nhưng hiện tại đã rõ ràng sạch sẽ hơn hẳn
Có người đã dành nhiều thời gian với Prolog và đang tìm một ngôn ngữ giúp việc kết hợp các hàm phi định tính cùng với kiểm tra kiểu lúc biên dịch trở nên dễ dàng
Ante cũng là một ứng viên đáng quan tâm
Họ cũng nhắc rằng không nên quên các công cụ cho lập trình viên và plugin editor như LSP, tree-sitter
Họ cho rằng với một ngôn ngữ mới, tooling phải là thứ bắt buộc ngay từ đầu
Vì cũng coi trọng trải nghiệm debug, họ đang cân nhắc liệu có thể cung cấp sẵn khả năng replayability, ít nhất trong debug mode hay không
Về nhận định "algebraic effects giống như exception có thể resume"
Có người hỏi liệu nó có giống Common Lisp conditions không
Họ thấy thú vị vì dường như đây là một khái niệm cũ được mang trở lại chỉ với tên mới
Có người đáp rằng algebraic effects bao quát hơn nhiều so với hệ thống condition của LISP
Ở điểm continuation có thể multi-shot, nó khá giống
call/cccủa SchemeCũng có nhắc rằng kiểu song song như vậy đôi khi còn có thể dẫn đến kết quả tệ hơn cả việc không có nó
Trong Smalltalk có "resumable exceptions"
Có người cho rằng nếu chỉ xem effect như một cách đổi tên của condition system cũ thì khó có thể đẩy cuộc thảo luận tiến lên
Algebraic effects đang được bàn đến hiện nay có những khác biệt vượt quá một khái niệm đơn giản như vậy
Dependency Injection cũng có thể được nhắc đến trong cùng ngữ cảnh này