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
SafeEvaluatorfromBasicObjectthx to @Mik-die
Comments
Mik-die commented 6 months ago
In fact you need inherit
class SafeEvaluator < BasicObjectbecause otherwise Object and Kernel bring a lot of standard methods, that 'overwrite' the same from original instancemakaroni4 commented 6 months ago
@Mik-die great idea, thanks a lot! Great addition to this hipster technique.
argent-smith commented 6 months ago
will read it thoroughly after a short nap
killthekitten commented 6 months ago
Inspired by factorygirl?
makaroni4 commented 6 months ago
@killthekitten good notice, syntax is really similar :)
7even commented 5 months ago
It would look more native using
#username=than#username, like in the original syntax.makaroni4 commented 5 months ago
@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
User.create!.tap{|u| }
mikeys commented 3 months ago
That's pretty damn awesome, kudos!