Bắt đầu bài với trò chơi đoán vui: bạn nghĩ error phổ biến nhất AppSignal track trong Ruby app là gì?
Fair to assume nhiều bạn trả lời NoMethodError, exception do gọi method không tồn tại trên object. Đôi khi do typo method name, nhưng thường do gọi method trên object sai type, thường là unexpected nil. Có gì Ruby developer làm để giảm frequency lỗi này?
Types To The Rescue?
Trừ choice của text editor hoặc programming language, ít topic spiral vào heated debates nhanh như type systems. Không đủ time deep dive, nhưng bài "What To Know Before Debating Type Systems" của Chris Smith làm excellent job.
Largest terms, type systems chia thành hai main category: static và dynamic. Static xảy ra ahead of time (via compiler hoặc separate tool), dynamic type checking xảy ra runtime, có thể dẫn exception nếu actual types không align với expectation của developer.
Proponents cả hai có strong opinions, nhưng cũng nhiều misconceptions: static typing không yêu cầu copious type annotations — nhiều modern compiler tự figure out types, process gọi "type inference". Ngược lại, dynamically typed languages không exhibit significantly higher defect rates so với statically typed counterparts.
Duck Typing
Ruby là dynamically type-checked language và follow "duck typing":
Nếu nó đi như vịt và kêu như vịt, thì nó phải là vịt.
Ý nghĩa: Ruby developer thường không lo lắng nhiều về type của object, mà care nó respond to certain "messages" (methods).
Vậy tại sao bother static typing trong Ruby? Mặc dù không phải panacea làm code magically bug-free, nó cung cấp benefits:
- Correctness: static typing tốt tại prevent certain classes bugs, như
NoMethodError. - Tooling: thường static type info available during development dẫn đến better tooling (refactoring support IDE, v.v.)
- Documentation: nhiều statically typed languages có built-in documentation tools tuyệt vời. Hoogle của Haskell dùng effect này offer search engine lookup function by type signatures.
- Performance: càng nhiều info cho compiler, càng nhiều performance optimizations có thể apply.
List không exhaustive, counterexamples cho hầu hết points, nhưng core of truth vẫn có.
Gradual Type Checking
Recent years, approach gọi "gradual type checking" làm inroads vào các dynamically type-checked languages: TypeScript cho JS, Hack cho PHP, mypy cho Python. Common point: không yêu cầu all-or-nothing, mà cho developer gradually add type info vào variables/expressions as they see fit. Đặc biệt useful cho large codebases hiện có, static check critical parts, phần còn lại untyped checked runtime. Tất cả type checking solutions Ruby chúng ta explore tiếp theo follow same approach.
Options
Sau khi xem tại sao Ruby developer muốn add static type checking, explore popular options hiện nay. Lưu ý: idea add static type checking vào Ruby không mới. Researchers University of Maryland làm Ruby extension Diamondback Ruby (Druby) early 2009, Tufts University Programming Language Group release paper "The Ruby Type Checker" 2013, dẫn đến project RDL, offer type checking và design-by-contract capabilities as library.
Sorbet
Developed by Stripe, Sorbet hiện là type checking solution Ruby được nói nhiều nhất, không chỉ vì big companies như Shopify, GitLab, Kickstarter, Coinbase early adopters closed beta. Announced Ruby Kaigi năm ngoái, first public release June 20 năm nay. Sorbet viết bằng modern C++ và dù Matz prefer (quote: "I hate type annotations"), opted cho approach dựa trên type annotations. Một điểm interesting: Sorbet opt cho combination static và dynamic type checking vì dynamic nature và metaprogramming capabilities của Ruby challenging cho static type systems.
# typed: true
class Test
extend T::Sig
sig {params(x: Integer).returns(String)}
def to_s(x)
x.to_s
end
end
Enable type checking, thêm magic comment # typed: true và extend class với T::Sig. Type annotation thực tế specified với method sig:
sig {params(x: Integer).returns(String)}
Chỉ định method nhận single argument x type Integer và return String. Gọi method với wrong argument type dẫn error:
Test.new.to_s("42")
# Expected Integer but found String("42") for argument x
Ngoài basic checks, Sorbet còn tricks hơn. Ví dụ, save từ dreaded NoMethodError trên nil:
users = T::Array[User].new
user = users.first
user.username
# Method username does not exist on NilClass component of T.nilable(User)
Snippet define empty array User objects, khi try access first element (return nil), Sorbet warn đúng không có method username trên NilClass. Nếu chắc chắn value không bao giờ nil, dùng T.must cho Sorbet biết:
users = T::Array[User].new
user = T.must(users.first)
user.username
Code trên type check, nhưng có thể lead runtime exception, dùng cẩn thận.
Sorbet còn nhiều hơn: dead code detection, type pinning (commit variable đến certain type, ví dụ once assigned string, never assign integer), define interfaces.
Ngoài ra, Sorbet work với "Ruby Interface" files (rbi) trong folder sorbet/ trong working directory. Cho phép generate interface definitions cho all gems project dùng, giúp tìm thêm type errors.
Nhiều hơn Sorbet hơn cover trong một bài (ví dụ varying strictness levels, metaprogramming plugins), nhưng docs khá tốt và open cho PRs.
Steep
Alternative widely known cho Sorbet là Steep bởi Soutaro Matsumoto. Không dùng annotations, không tự type inference. Thay vào đó, hoàn toàn rely trên files .rbi trong directory sig.
Bắt đầu từ simple Ruby class:
class User
attr_reader :first_name, :last_name, :address
def initialize(first_name, last_name, address)
@first_name = first_name
@last_name = last_name
@address = address
end
def full_name
"#{first_name} #{last_name}"
end
end
Scaffold initial user.rbi với command:
$ steep scaffold user.rb > sig/user.rbi
Kết quả file starting point (illustrated by tất cả types specified as any, no safety):
class User
@first_name: any
@last_name: any
@address: any
def initialize: (any, any, any) -> any
def full_name: () -> String
end
Thử type check lúc này, encounter errors:
$ steep check
user.rb:11:7: NoMethodError: type=::User, method=first_name (first_name)
user.rb:11:21: NoMethodError: type=::User, method=last_name (last_name)
Lý do: Steep cần special comment biết method nào defined through attr_readers, thêm:
# @dynamic first_name, last_name, address
attr_reader :first_name, :last_name, :address
Cũng cần add definitions cho methods vào generated .rbi. Khi đó, đổi signatures từ any sang actual types:
class User
@first_name: String
@last_name: String
@address: Address
def initialize: (String, String, Address) -> any
def first_name: () -> String
def last_name: () -> String
def address: () -> Address
def full_name: () -> String
end
Giờ mọi thứ works, steep check không return errors.
Ngoài ra, Steep support generics (ví dụ Hash) và union types, represent either-or choice giữa several types. Ví dụ: method top_post của user return highest-ranked post hoặc nil nếu chưa contribute. Represented qua union type (Post | nil), signature:
def top_post: () -> (Post | nil)
Mặc dù Steep ít features hơn Sorbet, vẫn helpful tool và seems more in line với Matz envisioned type checking Ruby 3.
Ruby Type Profiler
Yusuke Endoh (known as "mame" trong Ruby circles) từ Cookpad làm level 1 type checker gọi Ruby Type Profiler. Khác solutions khác, không cần signature files hoặc type annotations, mà try infer càng nhiều càng tốt về Ruby program while parsing. Dù catch ít potential problems hơn Steep hoặc Sorbet, nhưng zero extra cost cho developer.
Tóm tắt
Mặc dù không ai predict future, type checking trong Ruby seems here to stay. Hiện có efforts standardize "Ruby Signature Language" cho files .rbi (potentially scaffolded by Ruby Type Profiler), để developer dùng tool họ prefer. Steep đã cho library authors ship type info với gems, Sorbet có similar mechanism dạng sorbet-typed, inspired by DefinitelyTyped repo cho TypeScript definitions. Nếu quan tâm shape future của type checking Ruby, now is great time to get involved!