3 điểm bởi GN⁺ 2025-05-25 | Chưa có bình luận nào. | Chia sẻ qua WhatsApp
  • Hiệu ứng đại số là một tính năng ngôn ngữ bắt và xử lý luồng điều khiển giống như ngoại lệ có thể tiếp tục, là tính năng cốt lõi của Ante và cũng được dùng làm trung tâm trong các ngôn ngữ nghiên cứu như Koka, Effekt, Eff, Flix
  • Với cùng một cơ chế, có thể xây dựng generator, exception, async, coroutine, tự động vi phân ở cấp thư viện; nhờ đa hình hiệu ứng, các hàm như map cũng chỉ cần viết một lần bất kể loại hiệu ứng
  • Nếu biến dependency injection và truyền context như truy cập cơ sở dữ liệu, xuất dữ liệu, logging, truyền trạng thái thành hiệu ứng, có thể xử lý mock khi test, thu thập output, lọc log bằng cách thay handler
  • Khi các hiệu ứng như can IO, can Print, can Fail xuất hiện trong chữ ký hàm, điều đó có lợi cho bảo đảm tính thuần, ghi/phát lại và kiểm toán bảo mật, nhưng các hiệu ứng đã được cho phép có thể vô tình lan truyền đến handler hiện có
  • Điểm yếu truyền thống là lo ngại về hiệu năng, nhưng các ngôn ngữ gần đây đang giảm chi phí bằng tối ưu hóa hiệu ứng tail-resumptive, evidence passing, giới hạn một lần resume, và chuyên biệt hóa handler

Mô hình cơ bản của hiệu ứng đại số

  • Hiệu ứng đại số còn được gọi là effect handlers, và có thể hiểu theo mô hình “ngoại lệ có thể tiếp tục”
  • Trong mã giả Ante, ta khai báo hàm hiệu ứng và đánh dấu bằng can trong chữ ký hàm để cho biết có thể sử dụng hiệu ứng đó
    • Khi gọi một hàm hiệu ứng như say_message: Unit -> Unit, nó trở thành hình thức “ném” một hiệu ứng
    • Hàm gọi sẽ thể hiện khả năng dùng hiệu ứng đó trong chữ ký, như foo () can SayMessage
  • Biểu thức handle bắt hiệu ứng tương tự try/catch, rồi tiếp tục phép tính đang bị tạm dừng bằng lời gọi resume
    • Nếu handler của say_message thực thi print "Hello World!" rồi gọi resume (), phép tính ban đầu tiếp tục và trả về 42
  • Tên gọi “algebraic” phần lớn là thuật ngữ còn sót lại; trên thực tế, effect handlers gần với cách diễn đạt chính xác hơn, nhưng bài viết dùng tên hiệu ứng đại số vì đây là tên quen thuộc với người dùng

Luồng điều khiển do người dùng định nghĩa

  • Hiệu ứng đại số cho phép triển khai nhiều tính năng ngôn ngữ bằng một cơ chế duy nhất
  • Đa hình hiệu ứng giúp giảm vấn đề what color is your function
    • map (input: Vec a) (f: a -> b can e): Vec b can e diễn đạt rằng dù hàm đầu vào f thực hiện hiệu ứng e nào, map cũng thực hiện cùng hiệu ứng đó
    • Có thể dùng cùng một map với việc in ra stdout, gọi hàm bất đồng bộ, stream yield, v.v.
    • Nhiều ngôn ngữ effect handler cho phép lược bỏ biến hiệu ứng e, để viết theo dạng quen thuộc map (input: Vec a) (f: a -> b): Vec b
  • Có thể triển khai ngoại lệ bằng cách không gọi resume khi xử lý hiệu ứng
    • Định nghĩa throw: a -> never_returns của hiệu ứng Throw a
    • Khi chia cho 0, gọi throw "error: Division by zero!"; handler in thông báo rồi không tiếp tục phép tính
  • Generator có thể được triển khai bằng yield: a -> Unit của hiệu ứng Yield a
    • Duyệt qua các phần tử vector và gọi yield elem
    • Handler filter gọi lại yield x nếu giá trị được yield thỏa điều kiện, rồi tiếp tục sang phần tử kế tiếp bằng resume ()
    • Handler my_for_each thực thi hàm f cho từng giá trị được yield và tiếp tục bằng resume ()
  • Bộ lập lịch hợp tác cũng có thể được tạo bằng hiệu ứng yield: Unit -> Unit, trong đó handler nhận quyền điều khiển và chuyển sang thực thi hàm khác
  • Nhiều hiệu ứng kết hợp với nhau tốt, và đây được xem là ưu điểm giúp tăng tính dễ dùng so với các trừu tượng hóa hiệu ứng khác

Dependency injection và khả năng kiểm thử

  • Hiệu ứng cũng có thể được dùng cho dependency injection trong các ứng dụng nghiệp vụ thông thường
  • Thay vì truyền trực tiếp đối tượng cơ sở dữ liệu dưới dạng tham số hàm, có thể định nghĩa hiệu ứng Database
    • Dạng hiện có nhận đối tượng DB làm tham số, như business_logic (db: Database) (x: I32)
    • Dạng dựa trên hiệu ứng trở thành business_logic (x: I32) can Database, và bên trong gọi query "..."
  • Việc chọn cơ sở dữ liệu cụ thể do handler ở phía trên call stack đảm nhiệm
    • Có thể đổi DB vận hành sang DB khác hoặc thay bằng mock DB cho kiểm thử
    • Handler mock_database có thể bỏ qua thông điệp queryresume để luôn trả về DbResponse.Ok
  • Nếu cũng xử lý output như hiệu ứng, trong lúc test có thể thu thập thành chuỗi thay vì ghi trực tiếp ra stdout
    • Handler print_to_string bắt lời gọi print msg và tích lũy vào chuỗi all_messages kèm ký tự xuống dòng
    • output_messages có thể kiểm chứng giá trị trả về 1234 và chuỗi thông điệp mà không xuất thật
  • Logging có thể được chuyển thành output có điều kiện bằng hiệu ứng LogLogLevel
    • log_handler gọi print msg nếu level của thông điệp bằng hoặc cao hơn ngưỡng đã đặt
    • foo () with log_handler Error chỉ in log lỗi

API gọn hơn và truyền context

  • Hiệu ứng đại số có thể diễn đạt mẫu đối tượng Context được truyền xuyên suốt chương trình hoặc thư viện dưới dạng hiệu ứng
  • Hiệu ứng Use a có thể xem là hiệu ứng trạng thái, cung cấp get: Unit -> a, set: a -> Unit
    • Handler state lưu trạng thái ban đầu, trả về context hiện tại cho get, và cập nhật sang context mới cho set
    • Định nghĩa state trong ví dụ bỏ qua quy tắc ownership; trong triển khai thực tế có thể cần ràng buộc Copy a
  • Ví dụ lưu chuỗi trong vector và truyền index như khóa cho thấy chi phí của việc truyền context
    • Nếu không dùng hiệu ứng, push_string, get_string, append_with_separator, example, v.v. phải liên tục nhận strings làm tham số
    • Trong triển khai dựa trên hiệu ứng, các phép toán nguyên thủy push_string, get_string gọi get/set, còn mã cấp trên không cần truyền trực tiếp strings
  • Cách này phù hợp khi thư viện bọc việc truyền context nội bộ
    • Người dùng thư viện không cần bận tâm đến chi tiết bên trong của cách truyền context
    • Nếu không muốn bị ràng buộc vào một kiểu context cụ thể, có thể trừu tượng hóa các hàm cần thiết thành interface

Thay thế biến toàn cục và direct style

  • Những API nhìn bề ngoài như không trạng thái nhưng thực ra cần trạng thái, chẳng hạn sinh số ngẫu nhiên hoặc cấp phát bộ nhớ, có thể được diễn đạt bằng hiệu ứng thay vì biến toàn cục
  • Ví dụ sinh số ngẫu nhiên cho thấy gánh nặng khi phải truyền trực tiếp đối tượng Prng xuyên suốt chương trình
    • Prng toàn cục thì tiện, nhưng phát sinh nhược điểm của giá trị toàn cục, như cần đảm bảo an toàn luồng
    • Nếu dùng random: Unit -> U8 của hiệu ứng Random, người dùng chỉ cần chỉ rõ việc khởi tạo bằng handler ở đâu đó trong call stack phía trên
    • Sau đó, nếu muốn đổi sang /dev/urandom hoặc nguồn số ngẫu nhiên khác, chỉ cần thay handler, phần mã còn lại trong call stack không cần đổi
  • Cấp phát bộ nhớ cũng có thể được diễn đạt bằng hiệu ứng Allocate
    • allocate: (size: Usz) -> Alignment -> Ptr a
    • free: Ptr a -> Unit
    • Phần lớn lời gọi dùng allocator toàn cục; trong tight loop, có thể thêm handler vào thân vòng lặp để đổi sang arena allocator
  • Hiệu ứng cho phép direct style thay vì truyền kết quả được bọc trong giá trị chuyên dụng
    • Nếu dùng Maybe t, phải nối tiếp đường thành công bằng and_then, map
    • Cú pháp sugar như ? của Rust là cơ chế để tập trung vào đường đi tốt
    • get_line_from_stdin (): String can Fail, IOparse (s: String): U32 can Fail dựa trên hiệu ứng được viết như mã tuần tự thông thường: line = ..., x = ..., x * 2
  • Xử lý thất bại có thể được thực hiện bằng cách áp dụng handler để rời khỏi đường đi tốt
    • get_line_from_stdin () with default "42" xử lý hiệu ứng Fail bằng giá trị mặc định
  • Các kiểu lỗi khác nhau cũng được kết hợp tự nhiên bằng danh sách hiệu ứng
    • LibraryA.foo (): U32 can Throw LibraryA.Error
    • LibraryB.bar (): U32 can Throw LibraryB.Error
    • my_function có thể khai báo cùng lúc Throw LibraryA.Error, Throw LibraryB.Error, Throw MyError
    • Nếu việc lặp lại trở nên dài, có thể tạo type alias như AllErrors = can Throw ...
    • Cùng hiệu ứng Throw String sẽ được gộp thành một; nếu muốn tách riêng, cần một kiểu wrapper như MyError

Tính thuần, khả năng chạy lại, kiểm toán bảo mật

  • Hầu hết ngôn ngữ effect handler, ngoại trừ cỡ OCaml, dùng hiệu ứng ở những nơi có thể phát sinh side effect
    • Trong Ante, nếu không đánh dấu như can Print, can IO thì không thể dùng side effect
    • Định nghĩa extern không thể được trình biên dịch kiểm tra, nên phải tin vào định nghĩa kiểu
    • Việc chỉ thực hiện hiệu ứng IO trong debug mode để duy trì an toàn hiệu ứng ở release mode là một tính năng đã được lên kế hoạch
  • Một số hàm yêu cầu hàm thuần làm đầu vào
    • Khi tạo thread, thread được tạo không được phép gọi vào handler thuộc sở hữu của thread hiện tại
    • spawn_all (functions: Vec (Unit -> a pure)): Vec a can IO là dạng chỉ nhận hàm thuần, chạy tất cả hàm trong các thread rồi chờ hoàn tất
  • Software Transactional Memory(STM) là kỹ thuật đồng thời cần hàm thuần
    • Khi chạy nhiều hàm đồng thời, nếu một giá trị trong transaction bị thread khác thay đổi, transaction đó sẽ được bắt đầu lại
    • Triển khai proof-of-concept của Effekt nằm ở effekt-stm
  • Tính thuần có thể đem lại khả năng ghi/phát lại tương tự tiện ích gỡ lỗi rr
    • Hai handler recordreplay xử lý hiệu ứng cấp cao nhất mà main phát ra, thường là IO
    • record ghi lại sự phát sinh hiệu ứng và kết quả, rồi nâng tiếp lên handler IO tích hợp để xử lý thật
    • replay không thực hiện IO thật mà dùng kết quả trong log hiệu ứng
    • Nếu mặc định ghi lại trong debug build, có thể có được debugging mang tính xác định
  • Danh sách hiệu ứng trong chữ ký hàm giúp kiểm toán bảo mật theo cách tương tự Capability Based Security
    • get_pi: Unit -> F64 cho biết hàm không âm thầm thực hiện IO ở nền
    • Nếu sau khi cập nhật thư viện, nó trở thành get_pi: Unit -> F64 can IO, mã sẽ báo lỗi trừ khi hàm phía gọi đã yêu cầu IO
    • Nên khai báo chỉ các hiệu ứng tối thiểu; ví dụ chỉ khai báo Print thay vì toàn bộ IO
    • Việc thêm hiệu ứng mới được xem là thay đổi phá vỡ semantic versioning
    • Tài liệu liên quan gồm Capability Based SecurityDesigning with Static Capabilities and Effects

Hạn chế và chiến lược triển khai

  • Một hạn chế của cách tiếp cận hiệu ứng là có thể xảy ra xử lý ngoài ý muốn
    • Ngay cả khi một hàm mới yêu cầu IO, có thể sẽ không có lỗi nếu hàm gọi đã cho phép IO
    • Hiệu ứng Fail cũng tương tự: nếu một hàm thư viện trước đây không thất bại nhưng sau này có thể Fail, nó có thể lan truyền tới handler Fail hiện có
    • Hành vi này có thể ổn tùy tình huống, nhưng nếu bạn muốn xử lý riêng, chẳng hạn cung cấp giá trị mặc định, thì nó có thể không đúng ý
  • Nhược điểm lớn truyền thống là lo ngại về hiệu năng, nhưng output biên dịch của các hiệu ứng gần đây đã được cải thiện đáng kể
  • Nhiều ngôn ngữ hiệu ứng đại số tối ưu hóa hiệu ứng tail-resumptive thành lời gọi closure thông thường
    • Hiệu ứng tail-resumptive là hiệu ứng mà handler gọi resume ở cuối
    • Phần lớn hiệu ứng thực tế thuộc loại này, và hầu hết ví dụ trong bài cũng nằm trong nhóm này
    • Ngoại lệ được phân loại là trường hợp ngoại lệ vì hoàn toàn không gọi resume
  • Chiến lược tối ưu hóa cũng khác nhau theo từng ngôn ngữ
    • Koka dùng evidence passing, đẩy hiệu ứng lên tới handler và biên dịch sang C không cần runtime
    • AnteOCaml giới hạn resume chỉ được gọi tối đa một lần
      • Giới hạn này loại trừ một số hiệu ứng như tính phi tất định
      • Đổi lại, nó đơn giản hóa xử lý tài nguyên và cho phép triển khai continuation nội bộ hiệu quả hơn bằng các cách như segmented stacks
    • Effekt chuyên biệt hóa và loại bỏ hoàn toàn handler khỏi chương trình
      • Cách này có hạn chế là biến hầu hết hàm thành second-class
      • Có thể dùng dạng boxed để có first-class function và chuyển sang mô hình pay-as-you-go
      • Tài liệu liên quan gồm tài liệu captures của Effektbài báo

Chưa có bình luận nào.

Chưa có bình luận nào.