Cushion Defaults

What Does It Do?

TL;DR

An easy, flexible, and powerful alternative to hashes of defaults. Can be used both in individual classes and in complex class hierarchies.

The Long Version

Allows you to specify a "cushion" for various instance variables—effectively a default value—that will be returned by optional reader methods if the instance variable is undefined.

If, for example, the default value for @wheels in class Car is 4 and we set up a new instance called ford (ford = Car.new), then calling ford.wheels will return 4—even though no instance variable @wheels has been directly specified for ford. And if we later change the default value of wheels for Car (Car.defaults[:wheels] = 6), then all subsequent calls to ford.wheels will return 6 (unless we crystallize it beforehand—see below for more details).

The Latest

I try to keep all of this up-to-date, but the latest information can always be found in CHANGELOG.md.

Why Should I Care?

  1. Don't Repeat Yourself (DRY): Gather your defaults in one place, and specify them only once.
  2. Correspondingly, minimize the amount of code you have to write and maintain. You'll be writing x || default_value_for_x a lot less—and if you later change the default value for x, you only have to update a single line of code.
  3. Easily allow subclasses to inherit the default values of their ancestor classes or override them with their own default values. CushionDefaults is in this respect more flexible than using either constants or @@defaults variables. As an added bonus, changes to the defaults of a superclass cascade down and affect the defaults of subclasses.
  4. Optionally, if you think of your defaults as configuration rather than logic, pull them out of your code and put them in class-specific YAML files that can be automatically loaded in.
  5. Using the YAML technique, you can maintain multiple sets of defaults and load in the appropriate one depending on the environment.
  6. If you follow the common pattern of setting instance variables to the default value (e.g., @var = params[:var] || default_for_var), you have no way of distinguishing between when [email protected] is set to the default value because that was actively selected (params[:var]...) and when it is set to the default because it otherwise would have been nil (... || default_for_var). This is especially important when defaults change occasionally (or even vary regularly!). CushionDefaults makes this situation easy to handle.

Give Me a Quick Example

require 'cushion_defaults' # This will be assumed henceforth.
require 'color'

class Person
  include CushionDefaults

  # Set the cushion or default value for @favorite_color to the static value 'blue'
  self.defaults[:favorite_color] = 'blue'

  # Set the cushion for @favorite_shade_of_gray to the following proc.
  # As long as @favorite_shade_of_gray is not set, this proc will be evaluated at each call to #favorite_shade_of_gray.
  # If we wanted to fix or crystallize the value, we could call the bang version #favorite_shade_of_gray!
  self.defaults[:favorite_shade_of_gray] = proc do |instance|
      Color::RGB.by_name(instance.favorite_color).to_grayscale.to_rgb
  end

  # #cushion sets up a cushion_reader and cushion_writer for each of the symbols passed in
  cushion :favorite_color, :favorite_shade_of_gray
end

ryan, julia = Person.new, Person.new

ryan.favorite_color = 'blue'

# This value is computed by the proc cushion defined above, since ryan's @favorite_shade_of_gray isn't defined
ryan.favorite_shade_of_gray # RGB [#808080]

ryan.favorite_shade_of_gray = Color::RGB.by_name('silver') # RGB [#cccccc]

# Since ryan's @favorite_shade_of_gray is defined now, the proc isn't called
ryan.favorite_shade_of_gray # RGB [#cccccc]

ryan.favorite_color # 'blue'
ryan.has_specified?(:favorite_color) # true

julia.favorite_color # 'blue'

# When we call julia.favorite_color, we're only getting the default color
julia.has_specified?(:favorite_color) # false

# Now we set the default value for @favorite_color to a new value.
Person.defaults[:favorite_color] = 'green'

# ryan has a custom favorite color, so it doesn't affect him, but julia returns 'green' now
ryan.favorite_color # 'blue'
julia.favorite_color # 'green'

How Do I Get It?

gem install 'cushion_defaults' if you just want the gem.

If you want to help out the project or edit the source code, clone the repository (hosted at GitHub), fork it, make changes, and make a pull request.

Give Me the Rundown

The Basics

Setting up a DefaultsHash, populating it, and setting up cushion_readers and cushion_writers is a simple process.

class Plant
  include CushionDefaults
  self.defaults = {color: 'green', sunlight_needed: 'full'}

  # cushion_defaults is here equivalent to:
  #   cushion_reader :color, :sunlight_needed
  #   cushion_writer :color, :sunlight_needed
  cushion_defaults

  def needs_full_sunlight?
    sunlight_needed.eql?('full')
  end
end

rhododendron = Plant.new
rhododendron.color # 'green'
rhododendron.needs_full_sunlight? # true

Now, if we later decide to place our Plant class within Brandon Sanderson's Mistborn world, we may want to update our defaults:

Plant.defaults[:color] = 'brown'

As soon as we do this, all Plants that do not have a color explicitly assigned will return the new default value when we call their #color method.

rhododendron.color # 'brown'

Defaults and Inheritance

Classes inherit the defaults of those ancestors that respond to #defaults with a Hash or a descendent thereof.

This all takes place automatically. When CushionDefaults is included in a class, it automatically includes itself in all classes that subclass that class, and when a cushion_reader is called, it automatically moves up the class hierarchy if no value for the key is specified in the instance variable or in the current class.

class Klass
  include CushionDefaults
  self.defaults = {first: Klass, second: Klass, third: Klass}
  cushion_defaults
end

class SubKlass < Klass
  self.defaults += {second: SubKlass, fourth: SubKlass}
  cushion :fourth
end

class SubSubKlass < SubKlass
  self.defaults[:third] = SubSubKlass
end

x, y, z = Klass.new, SubKlass.new, SubSubKlass.new
z.first = 'custom'

Calling #first, #second, #third, and #fourth, then, would produce the following results on x, y, and z:

[x.first, x.second, x.third]
# [Klass, Klass, Klass]
# x.fourth would return NoMethodError

[y.first, y.second, y.third, y.fourth]
# [Klass, SubKlass, Klass, SubKlass]

[z.first, z.second, z.third, z.fourth]
# ['custom', SubKlass, SubSubKlass, SubKlass]

Obviously, changing the default of a parent class changes the value returned by subclass instances, unless they have explicitly overridden the default.

SubKlass.defaults[:second] = 'totally new value'
z.class # SubSubKlass, which < SubKlass
z.second # 'totally new value'

Adding and Removing Readers and Writers

Now, if we were to later add a new default to our Plant class from up above

Plant.defaults[:climate] = 'temperate'

and ran

rhododendron = Plant.new
rhododendron.climate

we would get a NoMethodError.

By default, CushionDefaults does not automatically add or remove readers and writers when defaults are added and removed. To change methods, you need to manually add readers and writers for the new default:

Plant.cushion :climate

If at any point you want to manually remove the cushion_reader or cushion_writer for a class (although the need for this should be rare, as you can simply overwrite it), you can run the following:

Plant.remove_reader :climate
Plant.remove_writer :climate

Alternatively, CushionDefaults can automatically add and remove methods for any new defaults added and any existing defaults removed. But to do that, we need to configure CushionDefaults.

Configuring CushionDefaults

There are two recommended techniques for configuring CushionDefaults (although a few other variations will work as well).

The simplest is to use CushionDefaults.configure, which yields a CushionDefaults::Configuration object that can be modified by a number of different methods, detailed in the docs.

CushionDefaults.configure do |conf|
  conf.update_readers = true
  conf.update_writers = true
end

If the above #configure call is placed immediately after the require statement, then no explicit calls to cushion, cushion_reader, or cushion_writer are needed.

CushionDefaults.configure do |conf|
  conf.update_readers = true
  conf.update_writers = true
end

class Chair
  include CushionDefaults
  self.defaults = {material: 'wood', comfort_factor: 5}
end

dining_room_hardback = Chair.new
dining_room_hardback.comfort_factor = 3

dining_room_hardback.material # 'wood'
dining_room_hardback.comfort_factor # 3

# automatically adds #number_accomodated and #number_accomodated=, because of above-specified options
Chair.defaults[:number_accomodated] = 1

dining_room_hardback.number_accomodated # 1

As an alternative to the CushionDefaults.configure block, you can define a cushion_defaults.yaml file. By default, CushionDefaults looks for this at config/cushion_defaults.yaml (relative either to the directory of the first file to require CushionDefaults or the gem's location in the file system). The YAML format is unremarkable, with the above CushionDefaults.config do ... end block equivalent to:

update_readers: true
update_writers: true

For a complete list of options available (along with explanations), see the docs for CushionDefaults::Configuration, especially the method group "Option Reader/Writers."

Proc Cushions

CushionDefaults now supports proc cushions, which offer a powerful new level of flexibility in getting and setting defaults.

If a default is set to a Proc, then cushion_readers will yield both an instance variable and a symbol representing the instance variable queried. (Since it's a proc, though, we do not need to worry about everything passed in.)

Take the following example:

class Language
    attr_accessor :say_hello
    def initialize(&block)
        yield self if block_given?
    end
end

$languages = {
        en: Language.new { |l| l.say_hello = 'Hello' },
        fr: Language.new { |l| l.say_hello = 'Bonjour' }
    }

class Person
    include CushionDefaults

    attr_accessor :name

    def initialize(&block)
        yield self if block_given?
    end

    self.defaults[:language] = $languages[:en]

    # By default, return the greeting for the person's language and the person's name
    self.defaults[:greeting] = proc do |instance|
        "#{instance.language.say_hello}, #{instance.name}"
    end

    cushion_defaults
end

peter = Person.new { |p| p.name = 'Peter' }
peter.greeting # 'Hello, Peter'

pierre = Person.new { |p| p.name = 'Pierre' }
pierre.greeting # 'Hello, Pierre', since languages[:en] is the default language

pierre.language = $languages[:fr]
pierre.greeting # 'Bonjour, Pierre', since languages[:fr] is now pierre's language, and #greeting gets its #say_hello

pierre.greeting = 'Salut!'
pierre.greeting # 'Salut!', since pierre has a custom greeting

It is also possible to combine this technique with calls to writer methods to produce a lazily-evaluated instance variable.

class Person
    include CushionDefaults

    self.defaults[:when_i_noticed_you] = proc do |instance|
        instance.when_i_noticed_you = Time.now
    end

    cushion :when_i_noticed_you
end

passerby = Person.new

# since @when_i_noticed_you is undefined, above proc sets it to Time.now
passerby.when_i_noticed_you # Time.now

# wait a sec
sleep(1.0)

passerby.when_i_noticed_you == Time.now # false—1 sec later

Alternatively, you can write a normal proc and call the variable's bang_reader if you're worried the variable may not be set.

class Person
    include CushionDefaults

    self.defaults[:when_i_noticed_you] = proc { Time.now }

    cushion :when_i_noticed_you
end

passerby = Person.new

# since @when_i_noticed_you is undefined the bang_reader sets it to Time.now
passerby.when_i_noticed_you! # Time.now

# wait a sec
sleep(1.0)

passerby.when_i_noticed_you == Time.now # false—1 sec later

These techniques can provide sophisticated means of both setting cushions or defaults while allowing customizable values for particular instances.

Freezing and Thawing Defaults

You may wish to prevent a default from further modification, either permanently or temporarily. This can prevent silly mistakes that are otherwise difficult to track down. CushionDefaults makes this possible via a freezing and thawing API. The key methods here are #freeze_default and #thaw_default.

class BuffaloNY
    include CushionDefaults
    self.defaults = {temperature: -10}
    freeze_default :temperature
end

# Raises CushionDefaults::FrozenDefaultError (< RunTimeError)
BuffaloNY.defaults[:temperature] = 60

# Assuming we caught the above error...
BuffaloNY.defaults[:temperature] == -10 # true

# Come summer, we can thaw the default
BuffaloNY.thaw_default :temperature

#And we can reset it without error
BuffaloNY.defaults[:temperature] = 60

Frozen defaults can still have their values overridden by child classes.

class NaturalLog
    include CushionDefaults
    self.defaults = {base: Math::E}
    freeze_default :base
end

class UnnaturalLog < NaturalLog; end

# Raises CushionDefaults::FrozenDefaultError
NaturalLog.defaults[:base] = 1i

# Works
UnnaturalLog.defaults[:base] = 1i

Note that frozen defaults can still have their values modified if those values are themselves mutable. To prevent this, we need to use #deep_freeze—but this should be done with caution.

(In some situations, even this can can fail to "fully" freeze an object. Check out ice_nine for a fuller solution.)

Finally, to freeze or thaw all defaults en masse, the API makes available #freeze_defaults and #thaw_defaults.

Storing Class Defaults in YAML Files

By default, CushionDefaults checks for YAML files for each class but does not complain if no YAML files are found. (If you want it to complain, set config.whiny_yaml to true.)

CushionDefaults looks for these YAML files at config/cushion_defaults/class_name.yaml. For class Klass, then, it would expect a config file at config/cushion_defaults/klass.yaml. Classes in a namespace are expected to have their YAML files in a folder named after their namespace, e.g. Modjewel::Klass in config/cushion_defaults/modjewel/klass.yaml.

These YAML files are completely unremarkable in form. Note that all defaults should be specified at root (not in defaults:), and currently only simple types are processed. For the above Chair class, we could place the defaults in a YAML class file like the following:

# config/cushion_defaults/chair.yaml
material: 'wood'
comfort_factor: 5
number_accomodated: 1

For an example of all of this in action, look at examples/example3/example3.rb and its class default files in examples/example3/config/cushion_defaults/.

You can specify a different YAML source folder relative to the calling directory (config/cushion_defaults/ by default) by setting config.yaml_source_folder, or you can specify an absolute path to the YAML source folder by setting config.yaml_source_full_path.

If you ever are bug-hunting and want to see where CushionDefaults expects a YAML file to be located, you can pass the class object to config.yaml_file_for(klass).

These YAML files are loaded automatically (unless config.auto_load_from_yaml has been set to false). But if you ever want to (wipe and) reload the defaults for a class—or load for the first time if the above option is disabled—use the class method defaults_from_yaml.

Managing Multiple Class Defaults

Multiple Sets of Class Defaults

You can use the above techniques to maintain different sets of class defaults for all of your classes. This is especially useful if your application needs to run in different environments or regions. For a more complex (but still simple enough) example, see Example 4 in the examples folder. Following is a trivial example.

Assume we have four YAML class defaults files as follows:

# config/cushion_defaults/spr/user.yaml
weather_judgment: 'how nice'
# config/cushion_defaults/sum/user.yaml
weather_judgment: 'is it hot in here or is it just me?'
# config/cushion_defaults/fal/user.yaml
weather_judgment: "if only it weren't for the leaves"
# config/cushion_defaults/win/user.yaml
weather_judgment: "baby it's cold outside"

Combine this with the following Ruby, and we can get different defaults depending on the current meteorological season.

class Season
  # Obviously this is only valid for the Northern hemisphere
  attr_accessor :months, :short_code

  def initialize(&block)
    yield(self) if block_given?
  end

  def include?(date)
    months.include?(date.month)
  end

  def yaml_source_path
    "config/cushion_defaults/#{short_code}/"
  end
end

seasons = [
  Season.new {|s| s.months=[3,4,5]; s.short_code='spr'},
  Season.new {|s| s.months=[6,7,8]; s.short_code='sum'},
  Season.new {|s| s.months=[9,10,11]; s.short_code='fal'},
  Season.new {|s| s.months=[12,1,2]; s.short_code='win'}
]

current_season = seasons.select{|s| s.include?(Date.today)}.first

# The following will set the root directory for all class defaults, depending on the current season.
# Possible resulting config paths:
#   - `config/cushion_defaults/spr/`
#   - `config/cushion_defaults/sum/`
#   - `config/cushion_defaults/fal/`
#   - `config/cushion_defaults/win/`
CushionDefaults.configure do |conf|
  conf.yaml_source_path = current_season.yaml_source_path
end

class User
  # Automatically loads in defaults from the above-selected path
  cushion :weather_judgment
end

# Returns one of the messages from the above YAML files, depending on the current meteorological season
User.new.weather_judgment

Multiple Defaults for a Single Class

Alternatively, if there is only a single class whose defaults you would like to load in one of several forms, you can do something like the following:

# select a random language
current_lang = ['en','fr','de'].sample
class Person
  include CushionDefaults
  defaults_from_yaml "#{self.to_s}_#{current_lang}"
  cushion_defaults
end

In this example, we load (randomly) either person_en.yaml, person_fr.yaml, or person_de.yaml. For a fuller example along these lines, see examples/example4/example4.rb.

Crystallizing Defaults

You can prevent auto-updating of default values, if desired, by calling #crystallize_default on those instances you don't want auto-updated. #crystallize_default(sym) effectively says "If no value for @sym is explicitly set, then explictly set it to the default value." Obviously, then, #crystallize_default(sym) affects only those instances that do not have a value explicitly specified for sym.

tulip, rose = Plant.new, Plant.new
tulip.color # 'brown'
rose.color = 'red'
tulip.has_specified?(:color) # false
rose.has_specified?(:color) # true

# crystallizes :color to 'brown'
tulip.crystallize_default(:color)

# has no effect, since :color is already set to 'red'
rose.crystallize_default(:color)

tulip.has_specified?(:color) # true

Plant.defaults[:color] = 'green'

tulip.color # 'brown'
rose.color # 'red'
Plant.new.color # 'green'

(Crystallizing defaults should be carefully distinguished from freezing defaults: crystallizing defaults applies to specific instances, whereas freezing defaults applies to the class itself.)

What About Persistence?

The key rule when using CushionDefaults with any sort of persistence is this: use instance variables, and not cushion_readers, when preparing objects for storage.

If you use cushion_readers when storing your objects, you run the risk of accidentally crystallizing your defaults. Take, for instance, the following (incorrect) code:

class AccidentallyInLove
    include CushionDefaults
    self.defaults[:girl_for_me] = proc { %w(Sally Jane Dora Annabel).sample }
    cushion :girl_for_me

    def marshal_dump
        # This is the problem: it returns the default for marshaling!
        [girl_for_me]
    end
    def marshal_load array
        self.girl_for_me = array.first
    end
end

marco = AccidentallyInLove.new

# only 0.4% chance these are the same!
5.times { puts marco.girl_for_me }

marco = Marshal.load(Marshal.dump(marco))

# Marco settled down without realizing it
5.times { puts marco.girl_for_me }

Assuming your intent in marshaling isn't to crystallize the default and force him to settle down, you can either leave in place the default methods (which work) or overload marshal_dump as follows:

def marshal_dump
    [@girl_for_me]
end

(Note, however, that any sort of reconstruction of objects is incompatible with setting Configuration.ignore_attempts_to_set_nil to false.)

Pushy and Polite Defaults

Pushy and polite defaults are an experimental feature. Rough documentation can be found in the docs, and more details will be forthcoming.

Testing and Bug Fixing

The most common testing configuration options are available by calling config.testing!.

You may find the following methods helpful in testing and bug fixing:

  • instance#has_specified?(sym): returns true if the instance has the instance variable denoted by sym defined
  • defaults#ish_keys: returns the keys of both its defaults and those it inherits from its parents
  • defaults#has_ish_key?(key): returns true if key is an ish_key.
  • defaults#where_is_that_default_again(sym): returns the closest ancestor class in which sym is defined as a default. If no ancestor class has it defined as a default, returns nil.

But I need...

If you need more than CushionDefaults offers right now, you've got a couple different options:

  1. Suggest a feature. Please explain why you think this feature would be valuable, and offer a couple different use cases to showcase how it would help people.
  2. Code a feature. Fork and pull, and I'll fold it in and implement it if it looks solid and generally useful.
  3. Check out Cascading Configuration. You may want to check out Cascading Configuration, which tackles a similar problem but offers a different approach and featureset.

Well, If You Ask Me...

Any feedback is very much appreciated!

For the Entomologists

Run into any bugs or issues? Please report them on the GitHub issue tracker.

Like It?

  1. Tell a friend.
  2. Star or fork the GitHub repository.
  3. If you're feeling generous, offer a tip.

Not Sold?

If you have the time, tell me why.

Not a useful concept? Don't like the implementation? Think the default configuration options should be different? Edit the wiki and let me know.

If you have any specific suggestions for how to make CushionDefaults better, I'd love to hear them.

Should I Stay or Should I Go Now?

Ultimately that's your decision. Try it, and see if it works for your use case. That said, here are some general guidelines from my perspective.

When Should I Use CushionDefaults?

When you need or want...

  1. DRY defaults handling.
  2. Flexibility.
  3. A powerful feature set for handling (and overriding) inheritance of defaults.
  4. To know when an instance variable is set to the default and when it is simply not specified.
  5. The ability to manage and maintain multiple sets of defaults (while remaining DRY).
  6. To separate your defaults (configuration) off from your codebase (logic).
  7. To gracefully handle changing defaults.
  8. To ensure that some or all of your defaults don't change.

When Shouldn't I Use CushionDefaults?

  1. When speed is absolutely critical. CushionDefaults is very fast (see benchmarks/simple_benchmark.rb), since it runs almost entirely on a series of hash lookups and adjusts methods on the fly depending on the current defaults setting. But there's no way it could consistently be as fast as attr_accessor: it just does more, and more computations means more time. Current benchmarks show execution speeds of 1.1 to 1.6 times those of attr_reader in Ruby 2.1.5, with a smaller factor in Ruby 1.9.3—but this is a difference of 0.01s to 0.05s for 100,000 calls to the reader methods. If you have hundreds of thousands of calculations that you need performed lightning fast, you should look elsewhere; but otherwise, CushionReader should be fast enough for your purposes.
  2. When working in a Rails environment. CushionDefaults may eventually spawn a companion project CushionDefaults-Rails, but for now it's just not the right tool for a Rails job. There are plenty of libraries that would be better for this purpose, e.g., default_value_for.
  3. When you want to keep your dependencies down. Some people end up with 150 apps on their phones; others end up with 150 gems in their projects. CushionDefaults itself doesn't depend on any other gems (in production), but that still doesn't mean it's worth the extra overhead to use it in every project.