Khi một ngôn ngữ lập trình không có lệnh `return`

Từ khóa return quá quen thuộc khi nó có mặt trong hầu như mọi ngôn ngữ lập trình, không chỉ được dùng ở cuối hàm như một lẽ thường, nó còn được dùng ở giữa hàm khi cần dừng chạy hàm sớm khi gặp điều kiện nào đó, ví dụ:

async def process_mqtt_message(
    topic: str, payload: bytes, pg_session: AsyncSession, red: Redis[str]
) -> ActuatorProcessResult | NodeProcessResult | None:
    if m := REGEX_TOPIC_SN_ACTUATOR_STATUS.match(topic):
        result = await works.process_sn_actuator_status_message(payload, m, pg_session, red)
        return result
    if m := REGEX_TOPIC_SN_NODE.match(topic):
        env_condition = await works.process_sn_node_message(payload, m, pg_session, red)
        return env_condition
    return None

Khi đến với Gleam, mình thấy nó táo bạo một cách thú vị khi bỏ luôn từ khóa này. Tuy nhiên điêù đó không có nghĩa là không có cách để "early return" trong Gleam. Gleam có những tính chất khác mà khi kết hợp lại thì vẫn thực hiện "early return" được.

Trước hết, trong Gleam, mọi dòng code đều là expression (biểu thức), tức là tương đương với một giá trị nào đó. Ví dụ:

  • x + 2 là một biểu thức vì trả về giá trị, ví dụ 3 nếu x == 1.

  • factorial(4) là một biểu thức vì trả về giá trị 24, là kết quả trả về của hàm factorial.

  • Khi có một khối gồm nhiều dòng code thì cả khối cũng là một expression, với giá trị là giá trị của dòng cuối cùng. Ví dụ:

    {
      let x = 3
      let y = x + 1
      y + 2
    }
    

    là một expression có giá trị 6 (vì y == 4).

  • Ngay cả câu lệnh gán biến, như let x = 3, với các ngôn ngữ lập trình khác thì là statement, nhưng trong Gleam thì vẫn là expression, với giá trị 3.

  • Theo quy tắc về khối bên trên thì một khối thân hàm cũng là một expression. Ví dụ khi hàm sau chạy:

    pub fn initiate_logout() -> Effect(Msg) {
      let handler = rsvp.expect_text(ApiReturnedLogOutDone)
      rsvp.post("/api/logout", json.bool(True), handler)
    }
    

thì giá trị trả về của dòng cuối, resvp.post(...) sẽ là giá trị trả về của cả hàm.

Rust cũng có quy ước "mọi thứ là expression" như thế, nhưng Rust vẫn có từ khóa return. Trong Gleam, việc "early return" dựa vào các cách sau:

  • Truyền vào một số hàm có hành vi rẽ nhánh, nghĩa là hàm sẽ thực thi một callback nếu điều kiện nào đó thỏa mãn. Ví dụ hàm result.map khi dùng:

    let x = Ok("Meow")
    result.map(x, fn(n) { io.println("Hello " <> n) })
    

    sẽ kiểm tra giá trị x, nếu là Error thì dừng ngay tại đó, nếu là Ok thì sẽ thực thi tiếp callback.

    Gleam có toán tử pipe (|>) nên đoạn code trên có thể viết lại như sau:

    let meow = Ok("Meow")
    meow |> result.map(fn(n) { io.println("Hello " <> n) })
    

    Một hàm nhiều câu lệnh có thể được triển khai dưới dạng nối ống nhiều hàm result.map, result.try thế này, để dừng sớm khi một đoạn ống nào đó không thoả điều kiện đi tiếp.

    let url = consts.api_posts <> id
    rsvp.parse_relative_uri(url)
    |> result.try(request.from_uri)
    |> result.map(request.set_header(_, "content-type", "application/json"))
    |> result.map(request.set_method(_, http.Patch))
    |> result.map(request.set_body(_, body))
    |> result.map(rsvp.send(_, handler))
    

    Nếu không có toán tử |>, và cú pháp function captures (với kí hiệu _), ta sẽ phải gán biến nhiều lần thế này:

    let url = consts.api_posts <> id
    let requ = result.try(rsvp.parse_relative_uri(url), request.from_uri)
    let requ = result.map(requ, fn(req) {
      request.set_header(req, "content-type", "application/json")
    })
    let requ = result.map(requ, fn(req) {
      request.set_method(req, http.Patch)
    })
    

    Cũng dễ hiểu, nhưng hơi rườm rà, có cảm giác dòng suy nghĩ bị chia cắt, không liên tục.

  • Không phải trường hợp nào cũng dùng được đường ống. Xét một ví dụ parse chuỗi JSON sau:

    {
      "id": 1,
      "email": "ng.hong.quan@gmail.com"
    }
    

    Ta muốn parse về một giá trị thuộc kiểu sau:

    type MiniUser {
      MiniUser(id: Int, email: String)
    }
    

    Ta sẽ thấy, phong cách API của việc parse / dump, serialize /deserialize dữ liệu trong Gleam rất khác với các ngôn ngữ phổ biến như Python, TypeScript. Ở ví dụ trên, ta sẽ phải định nghĩa một "decoder" theo phương pháp của thư viện gleam/dynamic/decode:

    pub fn mini_user_decoder() -> Decoder(MiniUser) {
      decode.field("id", decode.int, fn(id) {
        decode.field("email", decode.string, fn(email) {
          decode.success(MiniUser(id:, email:))
        })
      })
    }
    

    Hàm trên có nghĩa, thử tìm field "id" và parse thành số Int trước. Nếu thất bại (field không tồn tại hoặc giá trị đó không parse thành số được) thì dừng, nếu thành công thì thử tìm field "email" và làm tiếp. Như vậy hàm sẽ dừng sớm nếu thất bại ở bước nào đó. Nhưng nhìn đoạn code trên, bạn thấy gì quen không? Nó đó, đây là "callback-hell" hay gặp trong JavaScript trước thời async/await. Để làm code ngay ngắn hơn trong trường hợp lồng nhiều callback, Gleam có toán tử use <-, giúp code căn lề thẳng thớm như sau:

    pub fn mini_user_decoder() -> Decoder(MiniUser) {
      use id <- decode.field("id", decode.string)
      use email <- decode.field("email", decode.string)
      decode.success(MiniUser(id:, email:))
    }
    

    Cú pháp này ra kết quả đẹp, nhưng để hiểu nó cũng rối não, và mình sẽ trao đổi ở bài sau.