Proof of Concept: Go-like Implicit Interfaces with Ruby and Sorbet
The implicit interfaces in Golang are my favorite feature in the language. I think they make perfect sense to use with Object-Orientation as I’ve written about in a previous post. I love Ruby, I like the dynamic nature of it and how it relates to the Smalltalk, I was excited when I first heard about sorbet as it introduce the type checking optionally. However, I was worried that it will make the Object-Orientation in Ruby boring just like in C++ or Java.
TL;DR
Digging into the Sorbet internals to understand more about how Sorbet sees the types.
The implicit interfaces will be implemented only with Sorbet runtime, it wasn’t possible to do it with static type checking.
How Does Sorbet Work?
Sorbet works by using the existing features of ruby, to call the sig
method with a block before the method definition as follows:
class Printer
extend T::Sig
sig { returns(String) }
def print_it
x * x
end
end
The ordinary Ruby types work fine with Sorbet signatures with few exceptions, like Hash
, Array
and booleans (TrueClass
/FalseClass
), where they’re represented by Sorbet’s T::Hash[K, V]
, T::Array[T]
, and T:Boolean
respectively which is reasonable, because Ruby has pre-defined behaviors of this (i.e. Array[1,2,3]
creates an array).
Sorbet uses these signatures to learn about the types statically and dynamically. Each of them has their limitation, and Sorbet itself has its own limitation as well.
How Does Sorbet Understand The Types: Dynamically
For the type checking at runtime; sorbet creates wrappers around the method defined and validates the passed arguments against the type in the signature. In the signature, the type could be either a class
, module
, an array of them, or something is a T::Types::Base
(i.e. subtype of it).
When you pass a class
or a module
Sorbet will use something similar to .is_a?
on the object against that module. However, when you pass T::Types::Base
then it will do more complex checking, but not always works statically.
How Does Sorbet Understand The Types: Statically
It analyses the code and tries to recognize the types without the need to run the code (I think it does it via some AST). This has some limitations and it feels far less smart than a language that does it statically. For example, if you do Calculator.extend SomeModule
outside of the class, Sorbet will not able to know that statically.
It’s not perfect, but it still adds value and we use it to avoid many bugs before we even write tests for them.
Implicit Interfaces From T::Types::Base
I’ve found out that it’s possible to have basic interface checking without explicitly extending the interface (i.e. Module) by subclassing the T::Types::Base
.
When you have an interface that has some methods as follows:
module Printable
extend T::Sig
extend T::Helpers
interface!
sig { abstract.returns(String) }
def print
end
end
It’s great that Sorbet supports the concept of interfaces.
Then now we need to subclass the T::Types::Base
this will look like:
module T::Types
# Validates that an object can respond to specific messages.
class ProtocolOf < Base
extend T::Sig
Methods = T::Private::Methods
attr_reader :type
def initialize(type)
@type = type
end
# overrides Base
def name
"T::Protocol[#{@type}]"
end
# This is how Sorbet knows if this object is compatible or not.
# It should be performant, that's why there's very minimal checking here.
def valid?(obj)
instance_method_keys.all? do |key|
obj.respond_to?(key)
end
end
# This will be used for caching the value of .keys for performance.
def instance_method_keys
type_instance_methods.keys
end
# This is the 2nd checking, when the method is called.
# This method controls the error messages.
def error_message_for_obj(obj)
mismatched_methods = type_instance_methods.reject do |key, method_sig|
# Comparing the sorbet signature as well, not only the method existence.
obj.respond_to?(key) && method_signatures_equal?(signature_of_method(obj.method(key)), method_sig)
end
return if mismatched_methods.empty?
"expected #{obj.inspect} to respond to [#{instance_method_keys.join(', ')}] but it has missing: [#{mismatched_methods.keys.join(', ')}]"
end
def describe_obj(obj)
obj.inspect
end
private
# This has the same value for the same signature, caching will improve the performance
def type_instance_methods
@type_instance_methods ||= @type.instance_methods.to_h { |key| [key, signature_of_method(@type.instance_method(key))] }
end
# A factory that helps a bit
def signature_of_method(method)
Methods.signature_for_method(method)
end
# I want to compare the return type and the argument types against the interface.
# But what if the object doesn't use sorbet signature? Just ignore it.
def method_signatures_equal?(method_sig_1, method_sig_2)
return true if method_sig_1.nil? || method_sig_2.nil?
method_sig_1.return_type == method_sig_2.return_type &&
method_sig_1.arg_types == method_sig_2.arg_types
end
end
end
Now you have the type that Sorbet can understand, however, you can’t simply use it like: sig { params(obj: T::Types::ProtocolOf.new(Printable)).void }
. Sorbet follows some convention on how the type is used if we’re subclassing the T::Types::Base
, It should be in the T
module, this can be done as the following:
module T
module Protocol
extend T::Sig
sig { params(type: Module).returns(T::Types::ProtocolOf) }
def self.[](type)
T::Types::ProtocolOf.new(type)
end
end
end
# Then use it later like:
class SomeService
extend T::Sig
sig { params(obj: T::Protocol[Printable]).void }
def self.call(obj)
# ...
end
end
Now we can finally call the service either one of the ways:
printer_1 = Printer.new
SomeService.call(printer_1)
printer_2 = Struct.new(:print_it).new('print something')
# Yes! This works, if ignores the signature if not existed
SomeService.call(printer_2)
\
What If We Don’t Want To Create an Interface Or With No Type Signature?
Sometimes we don’t need to write ~10 lines of code of empty interface, what if we can just tell the method names? Like:
class SomeService
extend T::Sig
sig { params(obj: T::Protocol[%i[print_it]]).void }
def self.call(obj)
# ...
end
end
This is doable, since we are optionally checking for the Sorbet signature, and we are checking if the object responds to these messages or not. This will be supported in the final solution below. However, this doesn’t check against the parameters of the function.
The Final Solution
Here’s the final solution with support for on the fly types (i.e. Array of symbols) (With Sorbet Types):
module T::Types
# Validates that an object can respond to specific messages.
class ProtocolOf < Base
extend T::Sig
# Unfortunately Sorbet doesn't support T::Private namespace, so it's untyped.
Methods = T.let(T::Private::Methods, T.untyped)
sig { returns(T.any(Module, T::Array[Symbol])) }
attr_reader :type
sig { params(type: T.any(Module, T::Array[Symbol])).void }
def initialize(type)
@type = type
@type_instance_methods = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped]))
@instance_method_keys = T.let(nil, T.nilable(T::Array[Symbol]))
end
# overrides Base
sig { returns(String) }
def name
"T::Protocol[#{@type}]"
end
# No signature here, because it hugely affect the performance.
def valid?(obj)
instance_method_keys.all? do |key|
obj.respond_to?(key)
end
end
# No signature, because performances is affected
def instance_method_keys
@instance_method_keys ||= type_instance_methods.keys
end
sig { params(obj: Object).returns(T.nilable(String)) }
def error_message_for_obj(obj)
mismatched_methods = type_instance_methods.reject do |key, method_sig|
obj.respond_to?(key) && method_signatures_equal?(signature_of_method(obj.method(key)), method_sig)
end
return if mismatched_methods.empty?
"expected #{obj.inspect} to respond to [#{instance_method_keys.join(', ')}] but it has missing: [#{mismatched_methods.keys.join(', ')}]"
end
# overrides Base
sig { params(obj: Object).returns(String) }
def describe_obj(obj)
obj.inspect
end
private
sig { returns(T::Hash[Symbol, T.untyped]) }
def type_instance_methods
@type_instance_methods ||= case @type
when Array
@type.to_h { |key| [key, nil] }
when Module
@type.instance_methods.to_h { |key| [key, signature_of_method(@type.instance_method(key))] }
end
end
sig { params(method: T.any(Method, UnboundMethod)).returns(T.untyped) }
def signature_of_method(method)
Methods.signature_for_method(method)
end
sig { params(method_sig_1: T.untyped, method_sig_2: T.untyped).returns(T::Boolean) }
def method_signatures_equal?(method_sig_1, method_sig_2)
return true if method_sig_1.nil? || method_sig_2.nil?
method_sig_1.return_type == method_sig_2.return_type &&
method_sig_1.arg_types == method_sig_2.arg_types
end
end
end
By the way, I tried to support hash as well, something like { print_it: T.proc.returns(String) }, however, it seems that wasn’t very helpful, what I got was the builder of the signature, not the method signature.
I also tried to generate the Module
on the fly, but it didn’t work since I used define_method
which is not recognized by Sorbet.
Benchmarking
I’ve tested the performance with 100k calls.
require 'benchmark'
printer = Printer.new
puts Benchmark.measure {
100_000.times do
SomeService.call(printer)
end
}
After 3 runs, it gave me the following:
Without any caching and with all typing: 1.184733 0.003098 1.187831 ( 1.195597)
After caching the instance_method_keys
andtype_instance_methods
it gave: 0.465355 0.000549 0.465904 ( 0.465954)
After removing the type signature from valid?
and instance_method_keys
: It gave me: 0.260309 0.000382 0.260691 ( 0.260742)
which is close to using a simple class (it will be ~0.20)
Limitations
- Although this solution works with
sorbet-runtime
, it doesn’t support static checking. - No IDE support since the IDE has no idea what the type of parameter was sent from a non-standard sorbet type.
- It requires
T.unsafe
when used in union type (withT.any(..)
) when statically checked, however, it still complains even after adding it. Maybe it’s a bug. - Uses a private sorbet API like the
T::Private::Methods
, and they might break in the future.
Conclusion
It was nice getting my hands dirty playing and learning a lot about Sorbet types on the weekend. I wished that this supports static type checking, however, it’s not that easy at the current stage, I hope that sorbet changes that in the future, probably creating an API that helps with such parsing.