dry-initializer Join the chat at https://gitter.im/dry-rb/chat

Gem Version Build Status Dependency Status Code Climate Test Coverage Inline docs

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.