17 điểm bởi GN⁺ 2025-07-26 | 8 bình luận | Chia sẻ qua WhatsApp
  • Khi lập trình, có thể tận dụng hệ thống kiểu để phân biệt rõ ràng ý nghĩa của các dữ liệu khác nhau
  • Dùng nguyên các kiểu phổ thông như chuỗi hay số nguyên sẽ làm mất ngữ cảnh và có thể dẫn tới lỗi
  • Dù cùng một kiểu nền tảng, nếu định nghĩa kiểu mới phù hợp với mục đích sử dụng thì có thể ngăn sai sót bằng lỗi ở thời điểm biên dịch
  • Trong thư viện Go libwx, tác giả định nghĩa các kiểu phân biệt rõ đơn vị đo lường để tránh lỗi do trộn lẫn float64
  • Trong ví dụ mã, tác giả tách kiểu UUID thành UserIDAccountID để trình biên dịch chặn việc sử dụng sai
  • Ngay cả với những ngôn ngữ như Go, nơi hệ thống kiểu không quá mạnh, vẫn có thể phòng tránh lỗi bằng cách bọc kiểu đơn giản

Hãy chủ động tận dụng hệ thống kiểu

Điểm khởi đầu của vấn đề: trộn lẫn các kiểu đơn giản

  • Trong lập trình, người ta thường biểu diễn nhiều giá trị chỉ bằng các kiểu cơ bản như string, int, UUID
  • Nhưng khi quy mô dự án lớn dần, những kiểu đơn giản này rất dễ bị dùng lẫn lộn mà không phân biệt rõ
    • Ví dụ: vô tình truyền chuỗi userID thành accountID, hoặc truyền sai thứ tự trong một hàm có 3 đối số kiểu int

Giải pháp: định nghĩa kiểu thể hiện rõ ý đồ

  • int hay string chỉ là khối xây dựng cơ bản; nếu truyền nguyên chúng xuyên suốt hệ thống thì ngữ cảnh mang ý nghĩa sẽ bị mất đi
  • Để tránh điều đó, cần định nghĩa các kiểu riêng cho từng vai trò rồi sử dụng chúng
    • Ví dụ:
      type AccountID uuid.UUID  
      type UserID uuid.UUID  
      
      func UUIDTypeMixup() {  
          {  
              userID := UserID(uuid.New())  
              DeleteUser(userID)  
              // không lỗi  
          }  
      
          {  
              accountID := AccountID(uuid.New())  
              DeleteUser(accountID)  
              // lỗi: không thể dùng kiểu AccountID như UserID  
          }  
      
          {  
              accountID := uuid.New()  
              DeleteUserUntyped(accountID)  
              // không có lỗi ở thời điểm biên dịch, nhưng rất dễ phát sinh vấn đề khi chạy  
          }  
      }  
      
  • Cách này cho phép chặn đối số sai kiểu ngay ở thời điểm biên dịch

Trường hợp áp dụng thực tế: thư viện libwx

  • Tác giả đang áp dụng kỹ thuật này trong thư viện Go libwx của mình
  • Với mọi đơn vị đo lường, tác giả đều định nghĩa kiểu chuyên biệtgắn các phương thức chuyển đổi đơn vị vào chính kiểu đó
    • Ví dụ: dùng phương thức Km.Miles() để phân biệt rõ đơn vị
  • Dưới đây là ví dụ về việc trình biên dịch chặn thứ tự đối số sai và nhầm lẫn đơn vị:
    // khai báo nhiệt độ Fahrenheit  
    temp := libwx.TempF(84)  
    
    // khai báo độ ẩm tương đối (phần trăm)  
    humidity := libwx.RelHumidity(67)  
    
    // truyền sai vào hàm yêu cầu nhiệt độ Celsius thay vì Fahrenheit  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointC(temp, humidity))  
    // trình biên dịch phát hiện ngay lỗi mismatch kiểu  
    // temp (kiểu TempF) không thể dùng như TempC  
    
    // truyền sai thứ tự đối số vào hàm  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointF(humidity, temp))  
    // trình biên dịch ngăn lỗi kiểu đối số  
    
  • Nếu chỉ dùng float64, những sai sót như vậy đều có thể xảy ra, nhưng cách này giúp phòng tránh toàn bộ

Kết luận: hãy tích cực tận dụng hệ thống kiểu

  • Hệ thống kiểu không chỉ dùng để kiểm tra cú pháp mà còn là công cụ phòng tránh lỗi
  • Với mỗi model, nên định nghĩa riêng kiểu ID, và các đối số hàm cũng nên được bọc bằng kiểu rõ nghĩa thay vì dùng trực tiếp float hay int
  • Cách làm này rất hiệu quả và cũng dễ triển khai, ngay cả trong những ngôn ngữ như Go, nơi hệ thống kiểu không quá mạnh
  • Trên thực tế, lỗi do trộn lẫn UUID hoặc chuỗi xảy ra rất thường xuyên
  • Tác giả nhấn mạnh rằng thật đáng ngạc nhiên khi cách làm đơn giản này vẫn chưa được dùng phổ biến trong mã sản phẩm thực tế

Mã liên quan

8 bình luận

 
vk8520 2025-07-29

Theo tôi biết, khi dùng trong Kotlin, có thể sẽ có vấn đề về hiệu năng vì primitive bị bọc bằng wrapper nên được lưu trên heap thay vì stack. Tất nhiên, trong phần lớn use case, khả năng bảo trì vẫn được ưu tiên hơn. Ngoài ra, có thể giảm thiểu vấn đề hiệu năng bằng cách sử dụng value class.

 
regentag 2025-07-28

Ngôn ngữ Ada có một hệ thống kiểu rất xuất sắc ở khía cạnh này. Có thể dễ dàng khai báo các giá trị khác loại thành những kiểu riêng biệt, và khi chúng bị trộn lẫn, trình biên dịch sẽ phát hiện và chặn lại rất tốt.

 
roxie 2025-07-28

Cho tôi hỏi vì tò mò. Ngoài ra còn có những ưu điểm khác biệt so với các ngôn ngữ kiểu dữ liệu phổ biến khác không? (kotlin, rust, typescript, ...)

 
regentag 2025-07-28

Ưu điểm của Ada phần lớn nghiêng về phía “ổn hơn C”. Trong C, có khá nhiều thứ được cho phép vì tin tưởng vào lập trình viên. Những thứ như chuyển đổi kiểu ngầm chẳng hạn. Nhưng có vẻ đa số lập trình viên vẫn thích C hơn vì đã quen với nó...

Có thể đây là đặc điểm của codebase mà tôi đang làm việc, nhưng chúng tôi khai báo và sử dụng gần như mọi thứ bằng các kiểu riêng biệt. Kiểu cơ bản gần như chỉ dùng cho chỉ số mảng.

 
roxie 2025-07-28

Tôi hiểu rồi, cảm ơn bạn.

 
GN⁺ 2025-07-26
Ý kiến trên Hacker News
  • Tôi thích cách tiếp cận này, kiểu như "làm cho trạng thái xấu không thể biểu diễn được", nhưng vấn đề thường gặp của mẫu này là các lập trình viên chỉ dừng ở bước đầu của việc triển khai kiểu, mọi thứ đều thành kiểu riêng, không tương thích tốt với nhau, và xuất hiện rất nhiều kiểu chỉ khác biệt rất nhỏ khiến việc lần theo và hiểu code trở nên khó khăn; trong tình huống đó tôi thà viết bằng ngôn ngữ động kiểu yếu (JS) hoặc ngôn ngữ động kiểu mạnh (Elixir) còn hơn. Nhưng nếu các lập trình viên tiếp tục đẩy luồng thiết kế theo kiểu dữ liệu, ví dụ dồn logic điều kiện vào các union type có thể pattern match, tận dụng delegation tốt, thì trải nghiệm phát triển lại trở nên dễ chịu; chẳng hạn hàm DewPoint có thể được làm để hoạt động tự nhiên dù nhận nhiều kiểu khác nhau

    • Vì lý do này, tôi ước nhiều ngôn ngữ hơn hỗ trợ sẵn bounded type (kiểu có giới hạn phạm vi, như giới hạn miền giá trị của Integer), ví dụ thay vì x: u32 thì có thể buộc ở mức hệ thống kiểu rằng x chỉ được phép nằm trong khoảng [0,10). Khi đó sẽ không cần kiểm tra giới hạn khi truy cập mảng, và với Option thì các tối ưu hóa kiểu peephole cũng dễ hơn nhiều. Trong Rust, bên trong một hàm có phần nào được hỗ trợ nhờ LLVM, nhưng khi truyền biến qua giữa các hàm thì không có

    • Nhân tiện, Ruby không phải là kiểu yếu mà là kiểu mạnh. Nếu làm phép toán như 1 + "1" thì nó sẽ báo lỗi kiểu như TypeError: String can't be coerced into Integer

    • "Dừng lại ở bước đầu của việc triển khai kiểu" chính là nguyên nhân thất bại. Ví dụ, bắt đầu bằng cách bọc int trong struct để dùng làm UUID là một khởi đầu tốt, nhưng nếu ai đó chỉ cần có int là có thể bọc lại rồi truyền vào, thì thuộc tính UUID vốn phải duy nhất trên thực tế sẽ bị phá vỡ. Cuối cùng điều quan trọng là Correct by construction (đảm bảo đúng ngay từ lúc tạo ra), tức là với kiểu phải duy nhất như UUID thì cần chặn việc tạo ra nó trừ khi thực sự được chứng minh bằng hàm hay constructor nào đó, dù là bằng cách ném exception hay cơ chế khác. Khái niệm này không chỉ áp dụng cho UUID mà còn cho mọi kiểu và mọi bất biến

    • Gần đây tôi làm theo mẫu Red-Green-Refactor, nhưng thay vì dùng test thất bại thì tôi làm cho hệ thống kiểu nghiêm ngặt hơn để lỗi bị bắt bởi type checker. Tính năng mới, edge case, hay bug mà kiểu không thể dẫn tới lỗi thì vẫn xử lý bằng test, nhưng red-green-refactor dựa trên hệ thống kiểu nói chung nhanh hơn và có thể chặn hẳn cả một lớp bug lớn

    • Structural types có thể giảm nhẹ phần lớn vấn đề, và khi thực sự cần thì vẫn có thể ép bằng nominal types

  • Nói về thứ nằm cạnh exception và type, tôi nghĩ dùng checked exception cho tốt để xử lý phù hợp theo từng kiểu là điều hay. Tôi không hiểu vì sao checked exception của Java lại bị chê nhiều đến vậy. Khi tôi ép dự án mình phụ trách phải dùng checked exception, lúc đầu ai cũng ghét, nhưng khi đã quen với quá trình phải suy nghĩ về mọi trường hợp exception trong luồng code thì ai cũng thích, và dù unit test không quá nghiêm ngặt, dự án trở nên rất vững chắc

    • Sự bất mãn với checked exception trong Java là vì xử lý exception quá phiền. Người viết thư viện không thể quyết định checked exception một cách rõ ràng, còn phía client thì mỗi lần gọi hàm lại phải xử lý exception vô ích nên đâm ra ghét. Nếu có thể dễ dàng chuyển exception sang kiểu khác hoặc sang runtime exception, hay chỉ cần khai báo ở mức module/app, thì vấn đề này sẽ đỡ hơn, nhưng hiện tại quá rườm rà. Thêm nữa, vì rất dễ làm vỡ chữ ký hàm nên phải dùng exception theo domain, mà Java lại làm việc chuyển đổi exception trở nên bất tiện. Checked exception thì tốt, nhưng tôi ghét tính tiện dụng của cách Java xử lý exception

    • Checked exception bị chỉ trích là vì bị lạm dụng. Java hỗ trợ cả checked lẫn unchecked là một lựa chọn tốt. Nhưng tốt hơn là chỉ dùng checked exception cho những trường hợp như exception exogenous mà Eric Lippert nói tới, còn đa phần thì chuyển thành unchecked. Ví dụ DB có thể rớt kết nối bất kỳ lúc nào, nhưng cứ mang throws SQLException lên suốt cả call stack thì quá phiền. Chỉ cần xử lý kiểu catch-all ở tầng cao nhất và trả về HTTP 500 là được, bài liên quan

    • Checked exception (so với unchecked) có nhược điểm là nếu một hàm sâu trong call stack thay đổi để bắt đầu ném exception, thì không chỉ hàm xử lý mà toàn bộ các hàm ở giữa cũng có thể phải sửa theo. Tức là khi hệ thống thay đổi thì độ linh hoạt giảm đi. Tranh luận về async function coloring cũng có phần tương tự: nếu một hàm có thể ném exception thì hoặc phải bọc bằng try/catch, hoặc caller cũng phải khai báo là mình ném exception

    • C# có hệ thống kiểu rõ ràng nhưng dùng unchecked exception, và stack lỗi vẫn sạch sẽ, không vấn đề gì. Nó trông gọn hơn việc có các exception handler pattern matching rồi xử lý kiểu may đo ở từng tầng. Nếu có error result với khả năng unwrapping mạnh mẽ thì tôi nghĩ cũng tương tự

    • Trong Java còn có chuyện checked type có tính tiện dụng kém, ví dụ khi dùng stream API thì nếu hàm map/filter ném checked exception là thực sự rất khổ. Nếu gọi nhiều service mà mỗi cái có checked exception riêng thì cuối cùng hoặc phải bắt Exception, hoặc phải viết một danh sách exception dài vô lý

  • Về tổng thể thì tôi đồng ý với chủ trương "tạo kiểu riêng biệt", nhưng tôi cũng đã có nhiều trải nghiệm mệt mỏi với những hệ thống mà cái gì cũng là kiểu riêng, đặc biệt khi code chỉ để chuyển byte qua lại bị trộn lẫn với code tính toán domain thì cảm giác rất khó chịu

    • Tôi hiểu cảm giác đó. Dữ liệu cần thiết thì đã có rồi, nhưng trước hết lại phải đi tìm cách tạo kiểu hoặc tạo instance, nên nếu không có sẵn công thức thì cứ như vật lộn với tài liệu. Ví dụ bạn có object {x, y, z} nhưng lại phải dùng hàm createVector(x, y, z): Vector trước, rồi để tạo Face thì phải làm kiểu createFace(vertices: Vector[]): Face, khiến quy trình dài dòng không cần thiết. Với kiểu như BouncyCastle cũng vậy, dù đã có sẵn mảng byte nhưng vẫn phải tạo nhiều kiểu khác nhau và dùng method của chúng mới làm được thứ mình thực sự muốn

    • Trong Go, việc làm cho type alias quay trở lại kiểu gốc của nó (ví dụ AccountID → int) khá dễ. Nếu tổ chức tốt thì có thể theo kiểu clean architecture: logic domain dùng type alias, còn phía thư viện không cần quan tâm domain thì xử lý bằng cách chuyển lên/xuống các kiểu cao/thấp hơn. Nhưng bù lại sẽ cần rất nhiều code chuyển đổi

    • Phantom types rất hữu ích trong trường hợp này. Bạn thêm type parameter (tức generic), nhưng thực tế không dùng parameter đó ở đâu cả. Hồi trước khi viết code mã hóa trong Scala, mọi mảng đều là byte, nhưng tôi dùng phantom type để ngăn chúng bị trộn lẫn, ví dụ liên quan

    • Lý tưởng nhất là compiler chỉ cần kiểm tra type, rồi hạ toàn bộ logic domain còn lại xuống thành những thao tác copy byte đơn giản, nếu tôi hiểu đúng ý bạn

  • Tôi nghĩ hệ thống kiểu cũng áp dụng quy luật 80/20. Nếu đẩy quá mức thì việc dùng thư viện trở nên nặng nề mà lợi ích thực tế lại chẳng thêm bao nhiêu. UUID hay String thì quen thuộc, nhưng AccountID, UserID thì tôi không biết nên lại phải học thêm, chi phí khá cao. Một hệ thống kiểu cầu kỳ có thể đáng giá mà cũng có thể không, nhất là nếu test đã đủ tốt, tham khảo liên quan

    • Dù sao thì để dùng phần mềm, bạn cũng phải biết Account hay User là gì, nên tôi không nghĩ một hàm nhận AccountId như getAccountById lại khó hiểu hơn một hàm nhận UUID

    • Thực ra String chỉ là một tập byte, không tự mang ý nghĩa gì cả. Còn AccountID thì trong đa số trường hợp ai cũng hiểu là “ID của tài khoản”. Nếu thật sự muốn biết biểu diễn bên trong ra sao thì xem định nghĩa kiểu là được, nhưng trong hầu hết ngữ cảnh chỉ cần biết AccountID là gì là đủ. Kiểu dữ liệu, rốt cuộc, chỉ cần có cái tên rõ ràng thì khi dùng sẽ bớt nhầm lẫn hơn. Link grugbrain.dev kia thực ra còn quá cơ bản; nếu đúng kiểu grug brain thì còn phải ủng hộ mức tách kiểu thế này nữa

    • foo(UUID, UUID) kém hơn hẳn foo(AccountId, UserId), vì dạng sau tự mô tả hơn và compiler có thể bắt được trường hợp lỡ gọi sai thứ tự tham số. Ngay cả với cấu trúc dữ liệu phức tạp cũng có thể viết rõ ràng hơn mà không cần tạo kiểu mới

      Map<UUID, List<UUID>>
      Map<AccountId, List<UserId>>
      
    • Về ý "UUID hay String thì đã quen rồi", thực tế không hề dễ để hiểu chính xác UUID đang được lưu/chuyển đổi ở dạng nào, như GUIDv1, UUIDv4, UUIDv7, v.v. Theo kinh nghiệm của tôi, trong tổ hợp Java + MS SQL, khi chuyển đổi giữa UUID và uniqueidentifier đã từng phải tự xử lý vấn đề chuyển đổi endian. Tôi đoán nó giống kiểu rắc rối do tự động chuyển đổi múi giờ trong database

    • Thực ra việc cần hiểu những kiểu này vốn dĩ là điều phải có, nếu không thì bạn chỉ đang truyền dữ liệu sai vào hàm mà thôi

  • Gần đây đội của chúng tôi cũng áp dụng kiểu cho nhiều giá trị số đang bị trộn lẫn trong code C++. Ban đầu là vì đang tìm và sửa một bug nên đưa safe type vào, và rồi phát hiện thêm ba chỗ khác cũng đang dùng nhầm giá trị tương tự

  • Thư viện mp-units (tài liệu chính thức của mp-units) làm tôi nghĩ tới ví dụ tập trung vào bài toán đơn vị vật lý như thế này. Nếu dùng unit type mạnh thì vừa tăng an toàn, vừa tự động hóa được logic chuyển đổi đơn vị phức tạp, lại có thể xử lý nhiều loại unit bằng generic code. Tôi từng định đưa ý tưởng này sang thế giới Prolog nhưng đồng nghiệp xung quanh không mấy hưởng ứng, ví dụ cho Prolog

    • Trước đây tôi từng làm một dự án xử lý nhiều đại lượng vật lý khác nhau như khoảng cách, tốc độ, nhiệt độ, áp suất, v.v., nhưng tất cả đều chỉ truyền bằng float, nên dù lỡ đưa giá trị khoảng cách vào chỗ cần tốc độ thì vẫn compile bình thường và chỉ đến runtime mới lộ bug. Những lỗi do truyền sai đơn vị, như km/h với miles/h, cũng y hệt. Tôi từng muốn tăng thêm type để bắt các lỗi này ngay từ giai đoạn phát triển, nhưng lúc đó còn là junior nên rất khó thuyết phục mọi người

    • Tôi từng bỏ cuộc vì sợ việc áp type theo từng đơn vị vật lý sẽ quá phức tạp, nhưng giờ định xem thử mp-units. Đặc biệt là rất hay gặp vấn đề biến không nói rõ nó đang ở đơn vị nào, còn dữ liệu bên ngoài hay hàm chuẩn thì lại càng thường không ghi đơn vị

  • Trong C#, tôi tạo type như sau

    readonly struct Id32<M> {
      public readonly int Value { get; }
    }
    

    Khi đó

    public sealed class MFoo { }
    public sealed class MBar { }
    Id32<MFoo> x;
    Id32<MBar> y;
    

    theo cách này có thể phân biệt các integer ID khác nhau. Cũng có thể mở rộng ra IdGuid, IdString, v.v., và để thêm marker type mới (M) thì chỉ cần thêm một dòng. Trong TypeScript và Rust tôi cũng dùng các biến thể tương tự

    • Tôi cũng từng dùng một mẫu tương tự. Và nếu là ID kiểu int thì enum có lẽ là cách ít friction nhất, nhưng tôi thấy nó dễ gây rối quá nên chưa đưa vào code thực tế, thảo luận liên quan

    • Mẫu này được gọi là phantom type, vì giá trị của MFoo hay MBar không tồn tại ở runtime

    • Cũng có các thư viện cho mục đích này như Vogen. Vogen là viết tắt của Value Object Generator, hỗ trợ thêm các kiểu value object bằng sinh mã nguồn, và trong readme cũng có các thư viện tương tự cùng liên kết tham khảo

  • Tôi từng thấy cách này trước đây nhưng không hiểu nó để làm gì. Hôm nay khi viết một hàm nhận ba tham số kiểu chuỗi, tôi đang phân vân nên ép parse type từ trước hay xử lý trong hàm, nhưng thật ra lại là trường hợp không cần giá trị đã parse. Hóa ra đây chính là câu trả lời tôi đang tìm, và có lẽ sẽ là thứ ảnh hưởng lớn nhất đến phong cách code của tôi trong năm nay

  • Bạn tôi Lukas có tổng hợp ý tưởng này dưới tên gọi "Safety Through Incompatibility". Tôi đã áp dụng mẫu này khắp code golang và thấy cực kỳ hữu ích: nó chặn tận gốc chuyện truyền nhầm ID
    Bài liên quan 1
    Bài liên quan 2

  • Trong Swift có từ khóa typealias, nhưng nếu kiểu cơ sở giống nhau thì chúng vẫn có thể chuyển đổi tự do với nhau, nên trên thực tế không phù hợp cho mục đích này. Wrapper struct trong Swift lại khá hợp phong cách, và nếu tận dụng thêm ExpressibleByStringLiteral thì cũng tương đối tiện. Nhưng sẽ hay hơn nếu có một từ khóa mới kiểu "strong typealias" (typecopy chẳng hạn) để nói rõ rằng "đây vẫn chỉ là String, nhưng là String mang ý nghĩa đặc biệt nên đừng trộn nó với các String khác"

    • Thực tế đa số ngôn ngữ đều như vậy, ví dụ rust/c/c++ cũng thế, nên sẽ rất thích nếu không phải tạo wrapper type như ví dụ ở Go. Trong C++, nếu constructor không được đánh dấu explicit thì bạn còn có thể tự do nhét int vào chỗ cần kiểu Foo, nên càng phải cẩn thận hơn

    • Trên lý thuyết trông thanh lịch, nhưng áp dụng thực tế có thể phức tạp, như chuyện đưa vào std::cout trong C++, hay khả năng tương thích với hàm bên thứ ba hoặc extension point vốn trước giờ nhận String

    • Haskell có khái niệm này dưới tên newtype. Trong các ngôn ngữ OOP, nếu type không phải final thì có thể dễ dàng tạo subclass để thêm hay chuyên biệt hóa hành vi mong muốn. Cách này rẻ và đơn giản, không cần wrapper phụ hay boxing. Nhưng trong Java thì Stringfinal, nên làm như vậy khó hơn và rất khó để specialization chính String

    • Cụ thể thì bạn muốn nó hoạt động khác với wrapper struct như thế nào?

 
brain1401 2025-07-28

Rust cũng được dùng theo kiểu này mà, đúng là khá tốt.

 
regentag 2025-07-28

Nếu dùng một ngôn ngữ có hệ thống kiểu tốt thì liệu đã có thể ngăn được cả những việc như thế này chăng..
Tháng 9 năm 1999, tàu thăm dò quỹ đạo khí hậu Sao Hỏa của NASA bị mất tích

  • Do vấn đề liên kết dữ liệu giữa mô-đun dùng đơn vị pound và mô-đun dùng đơn vị newton khi biểu diễn độ lớn của lực, tàu thăm dò đã bị điều khiển sai và rơi.