HipsterStruct - new way for creating objects in Rails by makaroni4

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

Similar posts

Comments

Mik-die commented 6 months ago

7d116b912a4fc7986b40d5d0d0d811d6?size=52

In fact you need inherit class SafeEvaluator < BasicObject because otherwise Object and Kernel bring a lot of standard methods, that 'overwrite' the same from original instance

makaroni4 commented 6 months ago

E302c3320cd14b02cbe237b479d7f884?size=52

@Mik-die great idea, thanks a lot! Great addition to this hipster technique. :+1:

argent-smith commented 6 months ago

C12d2963163d64a5e22ae0f55a471b6e?size=52

will read it thoroughly after a short nap

7even commented 5 months ago

51551985507c89d0e745e148c20866b5?size=52

It would look more native using #username= than #username, like in the original syntax.

makaroni4 commented 5 months ago

E302c3320cd14b02cbe237b479d7f884?size=52

@7even agree, but as you know such syntax is used in FactoryGirl and it fits great there.

This post doesn't tend to make developers use this hipster syntax in production code but to show how to implement that using metaprogramming techniques in #ruby.

Furthermore I made a mistake in my code and @tenderlove wrote a great comment in #reddit with his own implementation of HipsterStruct:

link to reddit discussion

homakov commented 5 months ago

D2881b5d4c082996a62f23055b61956d?size=52

User.create!.tap{|u| }

mikeys commented 3 months ago

4b222ebb1857a0a2d01821fe5d4cf3ca?size=52

That's pretty damn awesome, kudos!

7even
Mik-die
argent-smith
homakov
killthekitten
makaroni4
mikeys