A colleague of mine proposed an idea for a new syntax for instantiating object in #rails:

User.create! do
  nickname = 'makaroni4'
  email = 'my_email@gmail.com'
end

As you can see there is no variable passed to a block and there is no context (self etc) inside the block. Is it possible? Under the cut there is an explanation of this case and implementation of similar syntax.

Let's begin by looking at typical case of creating object in Rails:

User.create! do |user|
  user.nickname = 'makaroni4'
  user.email = 'my_email@gmail.com'
end

So here we pass user variable into a block and each method is called on user object. But what if we don't have user variable? One way to solve this is to execute a given block in context of user object which is defined somewhere else. It could be done using BasicObject#instance_eval or BasicObject#instance_exec:

class User < Struct.new(:name)
end

user = User.new
almost_block = Proc.new { self.name = 'Finn' }
user.instance_eval &almost_block
user.name # => Finn

As you can see that we didn't pass any variable into a proc (or a block) but since we execute this block in context of user object self inside this block is our user.

But what if we don't have self? In this case we can't use name = 'Jake' statement because this is just local variable setting and not calling of user.name= method. So what can we do with that?

Since we don't want to use equals sign in our syntax we still have user as a self object inside a block and we just need to override reader method so if it is called with param it should work like setter method.

So I am a big fan of Struct class in Ruby so my implementation will be in some point similar to Struct, let's call it HipsterStruct:

class HipsterStruct
  def self.new *args
    klass = Class.new

    args.each do |meth_name|
      klass.class_eval %Q{
        def #{meth_name}(param = nil)
          if param
            @#{meth_name} = param
          else
            @#{meth_name}
          end
        end
      }
    end

    klass
  end
end

class User < HipsterStruct.new(:name, :email)
  def self.create! &block
    user = User.new
    user.instance_eval &block
    user
  end

  private
  def private_meth
    puts 'I am so private...'
  end
end

user = User.create! do
  name 'Snow king'
  email 'snow_king@gmail.com'
  private_meth
end

# I am so private...

puts user.email # => snow_king@gmail.com
puts user.name # => Snow king

Looks like we are done here but there is a couple of things to discuss. First – never use this approach. It is dangerous because instance_eval allows you to call private methods which I think is very bad. But let's make one more step and make our HipsterStruct safer. To hack instance_eval so it calls just public methods let's create class SafeEvaluator:

class SafeEvaluator < BasicObject
  def initialize obj
    @obj = obj
  end

  def method_missing method_name, *args
    @obj.public_send method_name, args
  end
end

Public instance_eval

SafeEvaluator just proxies any method call to user and make it safe. And in the end let me supply you with fully working example of what we discussed above:

class HipsterStruct
  def self.new *args
    klass = Class.new

    args.each do |meth_name|
      klass.class_eval %Q{
        def #{meth_name}(param = nil)
          if param
            @#{meth_name} = param
          else
            @#{meth_name}
          end
        end
      }
    end

    klass
  end
end

class SafeEvaluator < BasicObject
  def initialize obj
    @obj = obj
  end

  def method_missing method_name, *args
    @obj.public_send method_name, args
  end
end


class User < HipsterStruct.new(:name, :email)
  def self.create! &block
    user = User.new
    evaluator = SafeEvaluator.new(user)
    evaluator.instance_eval &block
    user
  end

  private
  def private_meth
    puts 'I am so private...'
  end
end

user = User.create! do
  name 'Snow king'
  email 'snow_king@gmail.com'
  private_meth
end

# private method `private_meth' called for #<User:0x007fad3184e370>

Benchmark

And this is actually not the end. @releu proposed to benchmark hipster way and I totally agree with it. Here is the benchmark code:

require 'active_record'
require 'benchmark/ips'
require 'benchmark'

ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  host: "localhost",
  database: "/Users/makaroni4/Desktop/RUBY/development.sqlite3",
  pool: 5,
  timeout: 5000
)

# ActiveRecord::Schema.define do 
#   create_table :users do |t|
#     t.string :username
#   end
# end

class SafeEvaluator < BasicObject
  def initialize obj
    @obj = obj
  end

  def method_missing method_name, *args
    @obj.public_send method_name, args
  end
end

class User < ActiveRecord::Base
  attr_writer :username

  def username name = nil
    if name
      @name
    else
      @name = name
    end
  end

  def self.hipster_create &block
    user = User.new
    evaluator = SafeEvaluator.new(user)
    evaluator.instance_eval &block
    user.save
  end
end

Benchmark.ips do |r|   
  r.report("normal create ") do
    User.create do |user|
      user.username = 'Princess BubbleGum'
    end
  end

  r.report("hipster way") do
    User.hipster_create do
      username 'Princess BubbleGum'
    end
  end
end

# Calculating -------------------------------------
#       normal create         75 i/100ms
#          hipster way        60 i/100ms
# -------------------------------------------------
#       normal create       744.5 (±11.4%) i/s -       3675 in   5.010707s
#          hipster way      644.1 (±12.3%) i/s -       3180 in   5.009812s

As you can see hipster way is a lil bit slower.

Updates

  • Inherit SafeEvaluator from BasicObject thx to @Mik-die

#ruby #metaprogramming