3 điểm bởi GN⁺ 2025-05-25 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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/catch củ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 map có 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 resume thì 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

 
GN⁺ 2025-05-25
Ý 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 foo hay bar có thể thất bại
    Muố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 foobar có 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ức with, rồi từ đó lại lần xuống theo handler tương ứng
    Khô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_function có thể được gọi ở nhiều nơi với nhiều handler khác nhau
    Tô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 ứng
      Hà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 throw exception, 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 DI
      Còn những phần liên quan đến iterator như yield thì chưa đào sâu

    • Có 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 foobar có thể thất bại
      Nhờ 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 khi g được thực thi

    • Có 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/resume cơ bản
    Họ 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 Database thì đ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ấp Database handler
      Cá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ể resume mã nhiều lần giống call/cc
      Ngược lại, với coroutine thì mỗi lần yield chỉ có thể được tiếp tục một lần
      Loạ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 được foo đá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 ApplicativeError hay MonadError
    Cá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 handle cũng gần như tương tự try/catch
    Các type class này vốn đã hỗ trợ bắt exception bằng những cách như handleError/handleErrorWith
    Ngườ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ơn
      Trong các ngôn ngữ thiên về monad như Haskell, nhờ suy luận type class theo kiểu mtl và cú pháp bind tích hợp sẵn, đôi khi cũng có thể tạo cảm giác khá giống

  • Ban đầ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 hỏi bạn không thích điểm nào ở static type system
  • 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

    • Từ OCaml 5.3 trở đi, effects đã tốt hơn rất nhiều so với trước
      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

    • Với tư cách là tác giả của Ante, có người cho biết hiện đã có hỗ trợ LSP, dù còn rất cơ bản
      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/cc của Scheme
      Cũ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