Function Signature Specifications (Typespecs)
The elixir borrowed a lot from Erlang . For example, both are languages ​​with dynamic
typing (which is fine, what would you be there categorical strict types in helmets
did not say). At the same time, in both languages ​​there are advanced features for
type checks when necessary.
Here is an introduction to typespecs , and here you can read more about them.
In short, we can define the specification of the function signature, and if the call does not meet the declared expectations, the static code analyzer, known as dialyzer , will swear. The format of these specifications looks pretty elegant:
@spec concat(binary(), any()) :: {:ok, binary()} | {:error, any()} def concat(origin, any), do: origin <> IO.inspect(any)
This function expects a string and any term at the input, and returns a string (obtained by concatenating the first parameter and converted to the string type of the second).
When we decided to support explicit type declarations for methods in Dry::Protocols
, I experimented a bit with syntax. I managed to almost completely repeat the elixir typespec
:
include Dry::Annotation @spec[(this), (string | any) :: (string)] def append(this, any) this << any.inspect end
This @spec
instruction @spec
parsed and executed by the standard ruby ​​parser. I want to tell how it was implemented. If you think you know how to ruby, I highly recommend not reading further, and trying to achieve the same result is fun.
Yes, I’m aware of contracts.ruby , but I didn’t want to drag such a slurred monster into a tiny application library, and I don’t trust the code from the Internet for a long time.
Syntax Selection
Well, to make the task more interesting, I deliberately decided to get the syntax as close as possible to the original. Of course, I could go on a boring monorail and announce a verbose, boring and annoying DSL, but I'm not such a wooden encoder.
So, let's see what the standard ruby ​​parser will allow us. A single instance variable will simply be ignored (well, for purists: the nil
value will be returned at the parsing stage and will be forgotten instantly), so we have about three options: assign a type value to it, or call it directly. I personally looked more at the option with calling @spec[...]
(which simply delegates @spec.call
under the hood).
- @spec = ... - @spec.(...) + @spec[...]
Now the options. The easiest way to feed the parser a bunch of anything is to create an instance of some omnivorous battery class and return self
from each method_missing
call. To avoid overlapping names to the maximum, I will inherit from BasicObject
, and not from the standard parent-parent Object
:
class AnnotationImpl < BasicObject def initialize @types = {args: [], result: []} end def ___μ(name, *args, &λ) @types[:args] << [args.empty? ? name : [name, args, λ]] self end end module Annotation def self.included(base) base.instance_variable_set(:@annotations, AnnotationImpl.new) base.instance_variable_set(:@spec, ->(*args) { puts args.inspect }) base.instance_eval do def method_missing(name, *args, &λ) @annotations.__send__(:___μ, name, *args, &λ) end end end end
It’s not by chance that I give such wild names to methods of this class: I would not want the heir to accidentally rewrite it. Yes, this approach is quite dangerous in principle, because we will now rewrite method_missing
in the Annotation
module, which will then be included wherever we need annotations.
Well, it's just a demonstration of the power of ruby, so that's fine. And by the way, for the original task of annotating methods in Dry::Protocols
, this is almost safe: the protocols are in principle very isolated and define only a few stand-alone methods: such a design.
Let's go. We already have everything to support the syntax of annotations of the form @spec[foo, bar, baz]
. It's time to include Annotation
in some class and see what happens.
class C include Annotation @spec[foo, bar, baz] end #⇒ NoMethodError: undefined method `inspect' for #<AnnotationImpl:0x00564f9d7e0e80>
Well, yes, BasicObject
. Define it:
def inspect @types.inspect end
VoilĂ . It already somehow works. In the sense, it does not swear at syntax errors and does not confuse the parser and interpreter.
Hardcore: boolean or for types
Well, the fun begins. I would like to not be limited to one type; you need to support Boolean or so that you can specify several allowed types! In the elixir, this is done with |
, well, we will do the same. It may not seem so easy, but in fact, no. Classes in Ruby allow method overrides #|
:
def |(_) @types[:args].push(@types[:args].pop(2).reduce(:+)) self # always return self to collect the result end
What's going on here? We got two elements from the array (this and the previous one), glued them,
keeping order and put it back into the array of arguments:
-
@types[:args]
before:[[:foo], [:bar], [:baz]]
where the:baz
just came in - after 2 pops:
[[:foo]]
and[[:baz], [:bar]].rotate.reduce(&:concat)
≡[[:bar, :baz]]
[[:baz], [:bar]].rotate.reduce(&:concat)
[[:bar, :baz]]
-
@types[:args]
after:[[:foo], [:bar, :baz]]
Not difficult. Also, to make the code cleaner and to avoid confusion with the order of method calls,
we will force users to explicitly wrap arguments in brackets @spec[(foo), (bar | baz)]
.
Nightmare level: type of result
Well, here I was expecting almost insoluble problems. Of course, I could use
hashrocket, as lazy unambitious raylists would do, but I'm not like that!
I wanted to achieve the elegance of the elixir syntax, with colons:
- @spec[(foo), (bar, baz) => (boo)] + @spec[(foo), (bar, baz) :: (boo)]
But how? - Yes, easily. As everyone knows, ruby ​​allows you to call methods using
not a period, but a double colon 42::to_s #⇒ "42"
, and not only class methods.
def call(*) @types[:result] << @types[:args].pop self # always return self to collect the result end
See how elegantly: the double colon just delegates the call to the call
method
instance receiver. Our implementation will just pull the last argument from the input array
(the whole line is “executed” from right to left), and it will be pushed into the result
array.
Honestly, I thought it would be harder.
Polishing: Attaching Annotations to Methods
You don’t need to do anything here: def
, which follows the annotation,
returns the instance of the method. Which will be automatically transferred as
argument to what @spec[]
will return. So we just get ourselves back, and
watering the method as an input argument - we attach an annotation to it. So simple.
To summarize
In fact, that’s all. The implementation is ready to use. Almost. Several Yet
cosmetic add- @spec
to allow several different @spec
calls
(similar to how desc
in a rake tasks definition collects all definitions
for future use), and documentation.
I would like to warn those who ran to implement it right now: no need
do it neither at home, nor at school, nor at work. Not because this code is complicated
(it’s simple), and not because it’s hard to read (it’s easy to read).
It’s just that in real life nobody needs it, ruby ​​is good because he
super, duck, dynamic, and typing it is only spoiling. Well and clog
the global namespace with all kinds of slag, as the rails like to do -
so-so practice. If you suddenly need to find a billion in your simple classroom
unnecessary, unknown from where methods come from - well, we already have ActiveSupport
.
Enough for our century.
The code I cite solely as an example of the almost unlimited possibilities of ruby
to fulfill any whim of a crazy developer.
Appendix I :: source
module Dry class AnnotationImpl < BasicObject def initialize @spec = [] @specs = [] @types = {args: [], result: []} end def ___λ &λ return @spec if λ.nil? (yield @spec).tap { @spec.clear } end def ___λλ @specs end def ___Λ @types end def to_s @specs.reject do |type| %i[args result].all? { |key| type[key].empty? } end.map do |type| "@spec[" << type. values. map { |args| args.map { |args| "(#{args.join(' | ')})" }.join(', ') }. join(' :: ') << "]" end.join(' || ') end def inspect @specs.reject do |type| %i[args result].all? { |key| type[key].empty? } end.inspect end def call(*) @types[:result] << @types[:args].pop self end def |(_) @types[:args].push( 2.times.map { @types[:args].pop }.rotate.reduce(&:concat) ) self end def ️___μ(name, *args, &λ) @types[:args] << [args.empty? ? name : [name, args, λ]] self end end module Annotation def self.included(base) annotations = AnnotationImpl.new base.instance_variable_set(:@annotations, annotations) base.instance_variable_set(:@spec, ->(*args) { impl = args.first last_spec = impl.___Λ.map { |k, v| [k, v.dup] }.to_h # TODO WARN IF SPEC IS EMPTY %i[args result].each do |key| last_spec[key] << %i[any] if last_spec[key].empty? end base.instance_variable_get(:@annotations).___λλ << last_spec base.instance_variable_get(:@annotations).___λ.replace([last_spec]) impl.___λλ << last_spec impl.___μ.each { |k, v| v.clear } }) base.instance_eval do def method_missing(name, *args, &λ) @annotations.__send__(:️___μ, name, *args, &λ) end end end end end
Questions, comments, errors? “I will answer with pleasure and argue.”