Ruby method signature specifications with syntax like in Elixir

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:









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.”








All Articles