Menu

World trong Zero: I/O tường minh, không có biến toàn cục

Zero không có stdout toàn cục, không có hệ thống tệp có sẵn ở khắp nơi, không có mạng ngầm. Mọi thứ chạm tới thế giới bên ngoài đều đi qua một capability World được truyền vào main. Đây là lý do và cách hoạt động.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Ý tưởng trong một câu

Chương trình Zero thực hiện I/O vì được cấp quyền làm việc đó, không phải bằng cách với tay lấy một biến toàn cục có sẵn.

Quyền đó là một giá trị kiểu World. Runtime tạo ra một giá trị như vậy trước khi gọi main, và chương trình của bạn truyền nó (hoặc từng phần của nó) đến bất cứ đâu cần tương tác với thế giới bên ngoài.

Vì sao không có biến toàn cục?

Hầu hết các ngôn ngữ cho phép bất kỳ hàm nào, ở bất kỳ đâu, ghi ra stdout hoặc mở một tệp. JavaScript có console.log. Python có print. C có printf. Sự tiện lợi là có thật, nhưng cái giá cũng vậy: bạn không thể nhìn chữ ký hàm mà biết hàm đó có thể thực hiện I/O hay không. Muốn biết, bạn phải đọc thân hàm, và đọc đệ quy.

Zero chọn một lập trường khác. Không có print toàn cục. Không có os.Stdout có sẵn ở khắp nơi. Nếu hàm của bạn thực hiện I/O, điều đó phải hiện ra trong chữ ký của nó, vì cách duy nhất để thực hiện I/O là được trao một capability.

Lợi ích thể hiện ở ba chỗ:

  1. Đọc chữ ký là biết hàm có thể làm gì. Một hàm không nhắc đến World thì không thể ghi ra stdout, không thể mở socket, không thể đọc tệp. Hệ thống kiểu biến điều đó thành một đảm bảo chắc chắn.
  2. Kiểm thử code thuần trở nên đơn giản. Hàm thuần không cần print giả hay hệ thống tệp giả lập: ngay từ đầu chúng đã không có quyền truy cập những thứ đó.
  3. Agent có thể suy luận cục bộ. Một agent AI sinh hoặc sửa code Zero có thể biết, mà không cần đọc toàn bộ codebase, liệu hàm nó đang xem có hiệu ứng hay không.

Cách dùng chuẩn

Bạn đã thấy hình dạng cơ bản ở bài hello-world:

Có ba điều đang diễn ra:

  • main khai báo tham số là world: World. Runtime trao cho chương trình một giá trị World và gắn nó vào đây.
  • world.out là luồng đầu ra chuẩn, được cung cấp như một trường của capability World.
  • world.out.write(...) ghi một chuỗi. Nó trả về một giá trị có thể thất bại (việc ghi có thể không thành công), và check truyền lỗi đó lên trên.

Bạn cũng có thể đổi tên tham số, chẳng hạn w: World hay io: World, nhưng world là quy ước chung và nên giữ để thống nhất với hệ sinh thái Zero rộng hơn.

Những gì có trên World

Capability World cung cấp các giao diện mà một chương trình có thể cần. Hình dạng chính xác phụ thuộc vào những gì runtime hỗ trợ, nhưng bạn có thể trông đợi các mục sau:

  • world.out: đầu ra chuẩn.
  • world.err: luồng lỗi chuẩn.
  • world.in: đầu vào chuẩn.
  • Một cách để mở tệp, đọc biến môi trường và kết nối qua mạng.

Hãy tham khảo tài liệu thư viện chuẩn Zero hiện tại để có danh sách trường chính xác. Một số giao diện (mạng, hệ thống tệp) có thể nằm sau các kiểu capability hẹp hơn, truy cập qua World thay vì ở cấp cao nhất.

Truyền capability qua code

Điểm phải đánh đổi, cái giá cho hiệu ứng tường minh, là mọi hàm cần thực hiện I/O đều phải nhận capability. Bạn không thể lấy nó một cách ngầm định.

fun log(world: World, message: String) -> Void raises {
    check world.out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world, "starting\n")
    log(world, "done\n")
}

log nhận world để có thể ghi qua nó. Nếu log không nhận world, thân hàm không thể gọi world.out.write, vì binding đó không tồn tại.

Cách này đòi hỏi truyền tham số nhiều hơn so với một ngôn ngữ có I/O sẵn ở khắp nơi. Đổi lại, toàn bộ đồ thị lời gọi của main giờ có thể nhìn thấy chỉ từ chữ ký:

  • main nhận world, nên nó có thể thực hiện I/O.
  • log nhận world, nên nó có thể thực hiện I/O.
  • Bất kỳ hàm nào không có world trong chữ ký thì không thể.

Capability hẹp hơn

Truyền cả World cho mọi hàm là một cách làm thô: giống như phát quyền root cho tất cả. Mẫu mà Zero khuyến khích là chỉ nhận đúng phần World bạn thực sự cần:

fun log(out: Stream, message: String) -> Void raises {
    check out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world.out, "starting\n")
    log(world.out, "done\n")
}

Giờ log chỉ có quyền truy cập một Stream (cùng kiểu mà world.out cung cấp). Nó có thể ghi qua luồng đó nhưng không thể mở tệp hay đọc từ mạng. Nơi gọi đã quyết định log được phép làm gì.

Tên kiểu chính xác bạn sẽ thấy trong code Zero thực tế (Stream, Writer, các phần capability) sẽ theo từ vựng của thư viện chuẩn trong phiên bản toolchain của bạn. Còn mẫu chung, truyền tối thiểu chứ không truyền tối đa, thì áp dụng ở mọi nơi.

Hàm thuần

Hàm không cần World thì không nên nhận World. Điều này một phần là lựa chọn phong cách, một phần do hệ thống kiểu bắt buộc: không có cách nào thực hiện I/O mà không có capability.

fun sum(point: Point) -> i32 {
    return point.x + point.y
}

sum là hàm thuần đối với thế giới bên ngoài. Nơi gọi nhìn vào chữ ký là biết chắc hàm này sẽ không in ra gì, không mở tệp, không gửi yêu cầu tới máy chủ nào. Đó là đặc tính mà một công cụ phân tích tĩnh (hoặc một agent) có thể dựa vào mà không cần đọc thân hàm.

Capability và raises

Gần như mọi thao tác trên một capability đều có thể thất bại. world.out.write có thể thất bại vì luồng đã đóng. Mở tệp có thể thất bại vì tệp không tồn tại. Các API capability đi kèm với raises và check: thao tác có thể thất bại khai báo các trường hợp thất bại trong chữ ký, và nơi gọi xác nhận chúng bằng check.

Sự kết hợp này là cốt lõi trong cách Zero xử lý hiệu ứng:

  • Điều gì có thể xảy ra → raises { ... }.
  • Qua cái gì → World (hoặc một phần của nó).
  • Ở đâu → bất cứ nơi nào capability và raises hiện ra.

Như vậy là đủ thông tin để suy luận chính xác về hiệu ứng của một hàm chỉ từ chữ ký của nó.

Một lưu ý về kiểm thử

I/O dựa trên capability khiến việc kiểm thử đơn giản ngay từ cấu trúc. Muốn bắt đầu ra của một hàm đang kiểm thử? Hãy truyền cho nó một capability out giả, ghi lại những gì nó được yêu cầu ghi. Muốn kiểm thử một hàm lẽ ra phải thuần? Đừng truyền cho nó capability nào: chữ ký kiểu của nó sẽ không cho phép nó chạm vào thế giới bên ngoài.

Thư viện chuẩn có thể cung cấp các bộ khung kiểm thử tạo capability giả hoặc capability trong bộ nhớ cho đúng mục đích này. API chính xác sẽ thay đổi cùng ngôn ngữ; nguyên tắc cốt lõi (capability là giá trị có thể thay thế) mới là đòn bẩy.

Tiếp theo: raises và check

World là một nửa câu chuyện về hiệu ứng: những giao diện mà một hàm có thể chạm tới. Nửa còn lại là thất bại: khi có gì đó sai, lỗi được truyền đi thế nào? Phần đó được trình bày tiếp theo trong Raises và check.

Câu hỏi thường gặp

World trong Zero là gì?

World là đối tượng capability do runtime cung cấp, cấp cho chương trình Zero quyền truy cập thế giới bên ngoài: stdout, stdin, tệp, mạng, biến môi trường, v.v. Runtime tạo một giá trị World và truyền nó vào main. Các hàm cần thực hiện I/O phải được truyền World (hoặc một phần hẹp hơn của nó): không có lối thoát toàn cục nào.

Vì sao main nhận một tham số World?

Zero không có biến toàn cục có sẵn ở khắp nơi. Không có thứ gì tương đương printf, console.log hay os.Stdout mà hàm nào cũng gọi được mà không cần quyền. Runtime trao cho main một capability World, và main (cùng mọi hàm nó gọi) chỉ có thể thực hiện I/O qua giá trị đó. Nhờ vậy mọi hiệu ứng đều hiện rõ trong chữ ký hàm.

I/O dựa trên capability khác I/O thông thường thế nào?

Trong hầu hết các ngôn ngữ, I/O là ngầm định: hàm nào cũng có thể ghi ra stdout hay đọc hệ thống tệp bất cứ lúc nào. I/O dựa trên capability biến quyền thực hiện I/O thành một giá trị: bạn phải được trao một World (hoặc một phần của nó) mới dùng được. Các hàm tính toán thuần không nhận World nên thực sự không thể thực hiện I/O, và hệ thống kiểu đảm bảo điều đó.

Có thể lấy World một cách ngầm định ở đâu đó sâu trong ngăn xếp lời gọi không?

Không, đó là thiết kế có chủ ý. Nếu một hàm hỗ trợ nằm sâu trong ngăn xếp lời gọi cần ghi ra stdout, bạn phải truyền World cho nó, hoặc một capability hẹp hơn, một cách tường minh. Cách này đòi hỏi truyền tham số nhiều hơn so với ngôn ngữ có I/O sẵn ở khắp nơi, nhưng đó là cái giá để chỉ cần đọc chữ ký hàm là biết hàm có thể thực hiện I/O hay không.

world.out.write làm gì?

world.out.write("text\n") ghi chuỗi được truyền vào luồng đầu ra chuẩn của chương trình thông qua capability mà runtime cung cấp. Nó trả về một giá trị có thể thất bại (việc ghi có thể không thành công), nên bạn bọc lời gọi bằng check để truyền lỗi lên trên ngăn xếp lời gọi.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu