dry-initializer 
DSL for building class initializer with params and options.
Installation
Add this line to your application's Gemfile:
gem 'dry-initializer'
And then execute:
$ bundle
Or install it yourself as:
$ gem install dry-initializer
Synopsis
require 'dry-initializer'
class User
extend Dry::Initializer
# Params of the initializer along with corresponding readers
param :name, type: String
param :role, default: proc { 'customer' }
# Options of the initializer along with corresponding readers
option :admin, default: proc { false }
end
# Defines the initializer with params and options
user = User.new 'Vladimir', 'admin', admin: true
# Defines readers for single attributes
user.name # => 'Vladimir'
user.role # => 'admin'
user.admin # => true
This is pretty the same as:
class User
attr_reader :name, :type, :admin
def initializer(name, type = 'customer', admin: false)
@name = name
@type = type
@admin = admin
fail TypeError unless String === @name
end
end
Params and Options
Use param to define plain argument:
class User
extend Dry::Initializer
param :name
param :email
end
user = User.new 'Andrew', '[email protected]'
user.name # => 'Andrew'
user.email # => '[email protected]'
Use option to define named (hash) argument:
class User
extend Dry::Initializer
option :name
option :email
end
user = User.new email: '[email protected]', name: 'Andrew'
user.name # => 'Andrew'
user.email # => '[email protected]'
All names should be unique:
class User
extend Dry::Initializer
param :name
option :name # => raises #<SyntaxError ...>
end
Default Values
By default both params and options are mandatory. Use :default key to make them optional:
class User
extend Dry::Initializer
param :name, default: proc { 'Unknown user' }
option :email, default: proc { '[email protected]' }
end
user = User.new
user.name # => 'Unknown user'
user.email # => '[email protected]'
user = User.new 'Vladimir', email: '[email protected]'
user.name # => 'Vladimir'
user.email # => '[email protected]'
Set nil as a default value explicitly:
class User
extend Dry::Initializer
param :name
option :email, default: proc { nil }
end
user = User.new 'Andrew'
user.email # => nil
user = User.new
# => #<ArgumentError ...>
You must wrap default values into procs.
If you need to assign proc as a default value, wrap it to another one:
class User
extend Dry::Initializer
param :name_proc, default: proc { proc { 'Unknown user' } }
end
user = User.new
user.name_proc.call # => 'Unknown user'
Proc will be executed in a scope of new instance. You can refer to other arguments:
class User
extend Dry::Initializer
param :name
param :email, default: proc { "#{name.downcase}@example.com" }
end
user = User.new 'Andrew'
user.email # => '[email protected]'
Warning: when using lambdas instead of procs, don't forget an argument, required by instance_eval (you can skip in in a proc).
class User
extend Dry::Initializer
param :name, default: -> (obj) { 'Dude' }
end
Order of Declarations
You cannot define required parameter after optional ones. The following example raises SyntaxError exception:
class User
extend Dry::Initializer
param :name, default: proc { 'Unknown name' }
param :email # => #<SyntaxError ...>
end
Type Constraints
To set type constraint use :type key:
class User
extend Dry::Initializer
param :name, type: String
end
user = User.new 'Andrew'
user.name # => 'Andrew'
user = User.new :andrew
# => #<TypeError ...>
You can use plain Ruby classes and modules as type constraint (see above), or use dry-types:
class User
extend Dry::Initializer
param :name, type: Dry::Types::Coercion::String
end
Or you can define custom constraint as a proc:
class User
extend Dry::Initializer
param :name, type: proc { |v| raise TypeError if String === v }
end
user = User.new name: 'Andrew'
# => #<TypeError ...>
Reader
By default attr_reader is defined for every param and option.
To skip the reader, use reader: false:
class User
extend Dry::Initializer
param :name
param :email, reader: false
end
user = User.new 'Luke', '[email protected]'
user.name # => 'Luke'
user.email # => #<NoMethodError ...>
user.instance_variable_get :@email # => '[email protected]'
No writers are defined. Define them using pure ruby attr_writer when necessary.
Subclassing
Subclassing preserves all definitions being made inside a superclass:
class User
extend Dry::Initializer
param :name
end
class Employee < User
param :position
end
employee = Employee.new('John', 'supercargo')
employee.name # => 'John'
employee.position # => 'supercargo'
Benchmarks
Various usages of Dry::Initializer
At first we compared initializers for case of no-opts with those with default values and time constraints (for every single argument):
no opts: 1186020.0 i/s
with 2 types: 744825.4 i/s - 1.59x slower
with 2 defaults: 644170.0 i/s - 1.84x slower
with defaults and types: 534200.0 i/s - 2.22x slower
Defaults are slow. The more defaults you add the slower the instantiation. Let's add details:
without defaults: 3412165.6 i/s
with 0 of 1 default used: 1816946.6 i/s - 1.88x slower
with 0 of 2 defaults used: 1620908.5 i/s - 2.11x slower
with 0 of 3 defaults used: 1493410.6 i/s - 2.28x slower
with 1 of 1 default used: 797438.8 i/s - 4.28x slower
with 1 of 2 defaults used: 754533.4 i/s - 4.52x slower
with 1 of 3 defaults used: 716828.9 i/s - 4.76x slower
with 2 of 2 defaults used: 622569.8 i/s - 5.48x slower
with 2 of 3 defaults used: 604062.1 i/s - 5.65x slower
with 3 of 3 defaults used: 533233.4 i/s - 6.40x slower
A single declaration of default values costs about 90% additional time. Its usage costs full 300%, and every next default adds 80% more.
Avoid defaults when possible!
Comparison to Other Gems
We also compared initializers provided by gems from the post 'Con-Struct Attibutes' by Jan Lelis:
Because the gems has their restrictions, in benchmarks we tested only comparable examples. A corresponding code in plain Ruby was taken for comparison.
Without Options
Results for the examples
Benchmark for instantiation of plain arguments (params):
Core Struct: 4520294.5 i/s
value_struct: 4479181.2 i/s - same-ish: difference falls within error
plain Ruby: 4161762.2 i/s - 1.09x slower
dry-initializer: 3981426.3 i/s - 1.14x slower
concord: 1372696.9 i/s - 3.29x slower
values: 637396.9 i/s - 7.09x slower
attr_extras: 556342.9 i/s - 8.13x slower
Benchmark for instantiation of named arguments (options)
dry-initializer: 1020257.3 i/s
plain Ruby: 1009705.8 i/s - same-ish: difference falls within error
kwattr: 394574.0 i/s - 2.59x slower
anima: 377387.8 i/s - 2.70x slower
With Default Values
Results for the examples
plain Ruby: 3534979.5 i/s
dry-initializer: 657308.4 i/s - 5.38x slower
kwattr: 581691.0 i/s - 6.08x slower
active_attr: 309211.0 i/s - 11.43x slower
With Type Constraints
Results for the examples
plain Ruby: 951574.7 i/s
dry-initializer: 701676.7 i/s - 1.36x slower
fast_attributes: 562646.4 i/s - 1.69x slower
virtus: 143113.3 i/s - 6.65x slower
With Default Values and Type Constraints
Results for the examples
plain Ruby: 2887933.4 i/s
dry-initializer: 532508.0 i/s - 5.42x slower
virtus: 183347.1 i/s - 15.75x slower
To recap, dry-initializer is a fastest DSL for rubies 2.2+ except for cases when core Struct is sufficient.
Compatibility
Tested under rubies compatible to MRI 2.2+.
Contributing
- Read the STYLEGUIDE
- Fork the project
- Create your feature branch (
git checkout -b my-new-feature) - Add tests for it
- Commit your changes (
git commit -am '[UPDATE] Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
The gem is available as open source under the terms of the MIT License.