Hãy tận dụng hệ thống kiểu
(dzombak.com)- 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
UserIDvàAccountIDđể 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
- 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
Giải pháp: định nghĩa kiểu thể hiện rõ ý đồ
inthaystringchỉ 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 } }
- Ví dụ:
- 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ệt và gắ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ị
- Ví dụ: dùng phương thức
- 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
floathayint - 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
- Có thể xem toàn bộ ví dụ trên GitHub:
https://github.com/cdzombak/libwx_types_lab
8 bình luận
Theo tôi biết, khi dùng trong Kotlin, có thể sẽ có vấn đề về hiệu năng vì
primitivebị 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ụngvalue class.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.
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, ...)Ư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.
Tôi hiểu rồi, cảm ơn bạn.
Ý 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
DewPointcó thể được làm để hoạt động tự nhiên dù nhận nhiều kiểu khác nhauVì 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: u32thì có thể buộc ở mức hệ thống kiểu rằngxchỉ đượ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ớiOptionthì 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
inttrongstructđể dùng làm UUID là một khởi đầu tốt, nhưng nếu ai đó chỉ cần cóintlà 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ếnGầ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
exogenousmà 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ứ mangthrows SQLExceptionlê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 quanChecked 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 exceptionC# 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/filterné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ắtException, 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àmcreateVector(x, y, z): Vectortrước, rồi để tạoFacethì phải làm kiểucreateFace(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ốnTrong 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 đổiPhantom 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,UserIDthì 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 quanDù 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
AccountIdnhưgetAccountByIdlại khó hiểu hơn một hàm nhận UUIDThực ra
Stringchỉ là một tập byte, không tự mang ý nghĩa gì cả. CònAccountIDthì 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ếtAccountIDlà 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ữafoo(UUID, UUID)kém hơn hẳnfoo(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ớiVề ý "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 databaseThự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ườiTô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
Khi đó
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
intthìenumcó 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 quanMẫu này được gọi là
phantom type, vì giá trị củaMFoohayMBarkhông tồn tại ở runtimeCũ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êmExpressibleByStringLiteralthì 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" (typecopychẳ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
explicitthì bạn còn có thể tự do nhétintvào chỗ cần kiểuFoo, nên càng phải cẩn thận hơnTrê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::couttrong 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ậnStringHaskell 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ảifinalthì 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ìStringlàfinal, nên làm như vậy khó hơn và rất khó để specialization chínhStringCụ thể thì bạn muốn nó hoạt động khác với wrapper struct như thế nào?
Rust cũng được dùng theo kiểu này mà, đúng là khá tốt.
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