πŸ“œ ⬆️ ⬇️

DSL and Ruby dynamic goodies

In this article, I will illustrate the main features of Ruby for building Domain Specific Languages ​​(DSL). DSL are small, highly specialized languages ​​for specific tasks. Unlike general purpose languages ​​such as C ++ or Java, DSLs are usually very compact and highly expressive in the context of the problem to be solved.

Various DSLs are common in Ruby libraries and frameworks. For example, Rails uses DSL to create migrations.

And now, let's see what features Ruby provides for building DSL

')
Let us need a simple format for describing a computer bundle.
A simple example:
 Processor: 2.2 GHz
 Memory: 1 gigabyte
 Disk: 250 gigabytes

Now with the help of Ruby we will build a convenient DSL for such descriptions.

Stage 1.


We transform this description into Ruby code, for example like this (let the memory be stored in megabytes and frequency in megahertz)
 comp = Computer.new
 comp.cpu = 2.2 * 1024
 comp.ram = 2 * 1024
 comp.disk = 1 * 1024

Elementary class code:
 class computer
     attr_accessor: cpu
     attr_accessor: ram
     attr_accessor: disk
 end

In Ruby, all instance variables (such variables begin with @) are private, i.e. only available inside object methods. To make an attribute, we must declare two methods to set and get the value of this attribute:
 def cpu
     @cpu
 end

 def cpu = val
     @cpu = val
 end

or more simply, call: attr_accessor :cpu , which will generate these methods for us

Stage 2.


So what? there is nothing new here, just use an object with attributes. Let's try to improve our code a little.
The first thing that catches your eye is that we must independently convert gigabytes to megabytes, and so on. Fix it!
 comp = Computer.new
 comp.cpu = 2.2.ghz
 comp.ram = 2.gb
 comp.disk = 1.gb

To do this, we add the ghz and gb methods to the Numeric class.
 class Numeric
     def ghz
         self * 1000
     end
     def gb
         self * 1024
     end
     def mhz
         self
     end
     def mb
         self
     end
 end

I also added two methods mhz and mb, cpu.ram = 512.mb instead of cpu.ram = 512.

Ruby has the ability to add (mixin) a new method to any class. Those. we can extend the class even after its creation. After we have mixed the method to the class, it becomes available for all its instances.
 class String
     def cool
         self + "is cool!"
     end
 end

In the cool self method, this is a pointer to the values ​​of the object itself, and since the return value of the method is the result of executing its last line,
puts "my string".cool
will display β€œMy string is cool!”

I used ghz methods and so on to the Numeric class, because this is the parent class for all numbers in Ruby. For both integer and fractional

Stage 3.


Already better. But still embarrassed by the fact that before each parameter we must specify "comp.". Let's do a little different:
 comp = Computer.new do
     cpu 2.2.ghz
     ram 2.gb
     disk 1.gb
 end

Looks a lot better, isn't it? But the question is will it work?
Let's see. In appearance, this is valid Ruby code. cpu, ram and disk are no longer methods, but functions, since they are not called from an instance of the Computer class.
Something like this:
 def cpu val
     comp.cpu = val
 end

But how do we pass comp into this function?
cpu 2.ghz comp? but then all expressiveness is lost. Now, if we could execute these methods in the context of this object ...
And because we can! Ruby gives us this opportunity using the instance_eval method.

Now look at the new implementation of the class Computer
 class computer
     # initialize method is a constructor
     def initialize & block # & block means that a block of code is passed to the method
         instance_eval & block # call the magic method instance_eval and pass it a block
     end

     # here I declared methods instead of attributes so that instead of cpu = 2.ghz I write cpu 2.ghz
     def cpu val
         @cpu_clock = val
     end

     def ram val
         @ram_size = val
     end

     def disk val
         @disk_size = val
     end

     # there is no need to set the value now, as there are methods cpu and so on
     # that's why i call attr instead of attr_accessor, it will not generate a method to set the value
     # but there is a small limitation, because  methods cannot be overloaded in dynamic languages
     # (a attr_accessor really creates methods)
     # then you need to choose other names for attributes
     attr: cpu_clock
     attr: ram_size
     attr: disk_size
 end


What does this method do? Everything is very simple. As already said above, it executes a block (or a line with a code) in the context of this object.
And since the Computer class declared methods cpu and so on, they will be called. And precisely for this object.

Stage 4.


Now imagine that our computer has an unlimited number of different characteristics. For example, BIOS size or bus size:
 comp = Computer.new do
     bios 0.5.mb
     bus 100
 end


We all can not predict, but I want to be able to add such characteristics.
This is where the Ruby features come to the rescue, namely the method_missing method.
method_missing is a special method of the object that is called when trying to call a nonexistent method. Example:
 class Test
     def method_missing name, * args, & block #name is the name of the method, * args is its attributes, & block is a block of code if there is one.
         puts name.to_s + "called"
     end
 end

 t = Test.new
 t.some_method # prints "some_method called"
 t.asdf # "asdf called"


Now back to the Computer class:
 class computer
     def initialize & block
         instance_eval & block
     end
    
     def method_missing name, * args, & block
         instance_variable_set ("@ # {name}". to_sym, args [0]) # create an instance variable and assign it a value
         self.class.send (: define_method, name, proc {instance_variable_get ("@ # {name}")}) # create a method to access this variable
     end
 end


Now how it works. We pass the block to the designer of our class. This block is executed in the context of an instance of this class. method_missing existent methods are called during execution, method_missing is executed method_missing with the name parameter equal to the method name and an array of args arguments. Now we create an instance variable with the same name as the method and a value equal to the first attribute of the called method. And also a method to get the value of this variable.

By the way, method_missing is used in Rails in ActiveRecord, for creating thousands of methods Person.find_all_by_name

The resulting code can be viewed here .

Further improvements


What else can you think of to make our DSL more convenient?
Since DSL can be designed not only for programmers, but also for people unfamiliar with programming, it is logical to put descriptions into a file that even non-programmers can edit. And then download and interpret this file.
my_pc.conf:
cpu 1.8.mgh
ram 512.mb
disk 40.gb

To do this is very simple, as I wrote above, you can pass an instance_eval method with a string with code instead of a block.

Secondly, for fans of the Russian language, you can write like this:
comp = Computer.new do
2.2.ghz
2.gb
1.gb
end

For this to work, you just need to add the -Ku parameter when invoking the interpreter. For example:
ruby -Ku test.rb

Above, we built a simple DSL, which is valid Ruby code. But you can refuse the validity. For example, get rid of the point.
Instead of cpu 1.ghz write cpu 1ghz . Then you have to do a little preprocessing. Add these points, for example using regular expressions.

And now, if we combine these improvements, we can interpret the example I gave at the very beginning:
 Processor: 2.2 GHz
 Memory: 1 gigabyte
 Disk: 250 gigabytes

Try it yourself :)

Now a little self-promotion :)
When I myself dealt with this technique, I wrote a simple DSL library for generating valid XHTML.
The point is that if we try to write something invalid, we get an error right during the generation process.
I designed it as a gem package, so you can put it like this:
on Windows: gem install rml
in * nix systems: sudo gem install rml
Project page: http://rubyforge.org/projects/rml/ .
Examples and documentation can be viewed: http://rml.rubyforge.org/ .
The truth is there in English, but if you are interested, I can write a small note.

Source: https://habr.com/ru/post/48754/


All Articles