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.