Skip to content

dmolesUC/typesafe_enum

Repository files navigation

TypesafeEnum

Build Status Code Climate Inline docs Gem Version

A Ruby implementation of Joshua Bloch's typesafe enum pattern, with syntax loosely inspired by Ruby::Enum.

Table of contents

Basic usage

Create a new enum class and a set of instances:

require 'typesafe_enum'

class Suit < TypesafeEnum::Base
  new :CLUBS
  new :DIAMONDS
  new :HEARTS
  new :SPADES
end

A constant is declared for each instance, with an instance of the new class as the value of that constant:

Suit::CLUBS
# => #<Suit:0x007fe9b3ba2698 @key=:CLUBS, @value="clubs", @ord=0>

By default, the value of an instance is its key symbol, lowercased:

Suit::CLUBS.key
# => :CLUBS
Suit::CLUBS.value
# => 'clubs'

But you can also declare an explicit value:

class Tarot < TypesafeEnum::Base
  new :CUPS, 'Cups'
  new :COINS, 'Coins'
  new :WANDS, 'Wands'
  new :SWORDS, 'Swords'
end

Tarot::CUPS.value
# => 'Cups'

And values need not be strings:

class Scale < TypesafeEnum::Base
  new :DECA, 10
  new :HECTO, 100
  new :KILO, 1_000
  new :MEGA, 1_000_000
end

Scale::KILO.value
# => 1000

Even nil is a valid value (if set explicitly):

class Scheme < TypesafeEnum::Base
  new :HTTP, 'http'
  new :HTTPS, 'https'
  new :EXAMPLE, 'example'
  new :UNKNOWN, nil
end

Scheme::UNKNOWN.value
# => nil

Declaring two instances with the same key will produce an error:

class Suit < TypesafeEnum::Base
  new :CLUBS
  new :DIAMONDS
  new :HEARTS
  new :SPADES
  new :SPADES, '♠'
end
typesafe_enum/lib/typesafe_enum/base.rb:88:in `valid_key_and_value': Suit::SPADES already exists (NameError)
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:98:in `register'
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:138:in `block in initialize'
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:137:in `class_exec'
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:137:in `initialize'
	from ./scratch.rb:11:in `new'
	from ./scratch.rb:11:in `<class:Suit>'
	from ./scratch.rb:6:in `<main>'

Likewise two instances with the same value but different keys:

class Tarot < TypesafeEnum::Base
  new :CUPS, 'Cups'
  new :COINS, 'Coins'
  new :WANDS, 'Wands'
  new :SWORDS, 'Swords'
  new :STAVES, 'Wands'
end
/typesafe_enum/lib/typesafe_enum/base.rb:92:in `valid_key_and_value': A Tarot instance with value 'Wands' already exists (NameError)
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:98:in `register'
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:138:in `block in initialize'
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:137:in `class_exec'
	from /Users/dmoles/Work/Stash/typesafe_enum/lib/typesafe_enum/base.rb:137:in `initialize'
	from ./scratch.rb:11:in `new'
	from ./scratch.rb:11:in `<class:Tarot>'
	from ./scratch.rb:6:in `<main>'

However, declaring an identical key/value pair will be ignored with a warning, to avoid unnecessary errors when, e.g., a declaration file is accidentally loaded twice.

class Tarot < TypesafeEnum::Base
  new :CUPS, 'Cups'
  new :COINS, 'Coins'
  new :WANDS, 'Wands'
  new :SWORDS, 'Swords'
end

class Tarot < TypesafeEnum::Base
  new :CUPS, 'Cups'
  new :COINS, 'Coins'
  new :WANDS, 'Wands'
  new :SWORDS, 'Swords'
end

# => ignoring redeclaration of Tarot::CUPS with value Cups (source: /tmp/duplicate_enum.rb:13:in `new')
# => ignoring redeclaration of Tarot::COINS with value Coins (source: /tmp/duplicate_enum.rb:14:in `new')
# => ignoring redeclaration of Tarot::WANDS with value Wands (source: /tmp/duplicate_enum.rb:15:in `new')
# => ignoring redeclaration of Tarot::SWORDS with value Swords (source: /tmp/duplicate_enum.rb:16:in `new')

Note: If do you see these warnings, it probably means there's something wrong with your $LOAD_PATH (e.g., the same directory present both via its real path and via a symlink). This can cause all sorts of problems, and Ruby's require statement is known to be not smart enough to deal with it, so it's worth tracking down and fixing the root cause.

Ordering

Enum instances have an ordinal value corresponding to their declaration order:

Suit::SPADES.ord
# => 3

And enum instances are comparable (within a type) based on that order:

Suit::SPADES.is_a?(Comparable)
# => true
Suit::SPADES > Suit::DIAMONDS
# => true
Suit::SPADES > Tarot::CUPS
# ArgumentError: comparison of Suit with Tarot failed

String representations

The default to_s implementation provides the enum's class, key, value, and ordinal, e.g.

Suit::DIAMONDS.to_s
# => "Suit::DIAMONDS [1] -> diamonds"

It can of course be overridden.

Enumerable

As of version 0.2.2, TypesafeEnum classes implement Enumerable, so they support methods such as #find, #select, and #reduce, in addition to the convenience methods called out specifically below.

Convenience methods on enum classes

#to_a

Returns an array of the enum instances in declaration order:

Tarot.to_a
# => [#<Tarot:0x007fd4db30eca8 @key=:CUPS, @value="Cups", @ord=0>, #<Tarot:0x007fd4db30ebe0 @key=:COINS, @value="Coins", @ord=1>, #<Tarot:0x007fd4db30eaf0 @key=:WANDS, @value="Wands", @ord=2>, #<Tarot:0x007fd4db30e9b0 @key=:SWORDS, @value="Swords", @ord=3>]

#size

Returns the number of enum instances:

Suit.size
# => 4

#each, #each_with_index, #map and #flat_map

Iterate over the set of enum instances:

Suit.each { |s| puts s.value }
# clubs
# diamonds
# hearts
# spades

Suit.each_with_index { |s, i| puts "#{i}: #{s.key}" }
# 0: CLUBS
# 1: DIAMONDS
# 2: HEARTS
# 3: SPADES

Suit.map(&:value)
# => ["clubs", "diamonds", "hearts", "spades"]

Suit.flat_map { |s| [s.key, s.value] }
# => [:CLUBS, "clubs", :DIAMONDS, "diamonds", :HEARTS, "hearts", :SPADES, "spades"]

#find_by_key, #find_by_value, #find_by_ord

Look up an enum instance based on its key, value, or ordinal:

Tarot.find_by_key(:CUPS)
# => #<Tarot:0x007faab19fda40 @key=:CUPS, @value="Cups", @ord=0>
Tarot.find_by_value('Wands')
# => #<Tarot:0x007faab19fd8b0 @key=:WANDS, @value="Wands", @ord=2>
Tarot.find_by_ord(3)
# => #<Tarot:0x007faab19fd810 @key=:SWORDS, @value="Swords", @ord=3>

#find_by_value_str

Look up an enum instance based on the string form of its value (as returned by to_s) -- useful for, e.g., XML or JSON mapping of enums with non-string values:

Scale.find_by_value_str('1000000')
# => #<Scale:0x007f8513a93810 @key=:MEGA, @value=1000000, @ord=3>

Enum classes with methods

Enum classes are just classes. They can have methods, and other non-enum constants. (The :initialize method for each class, though, is declared programmatically by the base class. If you need to redefine it, be sure to alias and call the original.)

class Suit < TypesafeEnum::Base
  new :CLUBS
  new :DIAMONDS
  new :HEARTS
  new :SPADES

  ALL_PIPS = %w(   )

  def pip
    ALL_PIPS[self.ord]
  end
end

Suit::ALL_PIPS
# => ["♣", "♦", "♥", "♠"]

Suit::CLUBS.pip
# => "♣"

Suit.map(&:pip)
# => ["♣", "♦", "♥", "♠"]

Enum instances with methods

Enum instances can declare their own methods:

class Operation < TypesafeEnum::Base
  new(:PLUS, '+') do
    def eval(x, y)
      x + y
    end
  end
  new(:MINUS, '-') do
    def eval(x, y)
      x - y
    end
  end
end

Operation::PLUS.eval(11, 17)
# => 28

Operation::MINUS.eval(28, 11)
# => 17

Operation.map { |op| op.eval(39, 23) }
# => [62, 16]

How is this different from Ruby::Enum?

Ruby::Enum is much closer to the classic C enumeration as seen in C, C++, C#, and Objective-C. In C and most C-like languages, an enum is simply a named set of int values (though C++ and others require an explicit cast to assign an enum value to an int variable).

Similarly, a Ruby::Enum class is simply a named set of values of any type, with convenience methods for iterating over the set. Usually the values are strings, but they can be of any type.

# String enum
class Foo
  include Ruby::Enum

  new :BAR, 'bar'
  new :BAZ, 'baz'
end

Foo::BAR
#  => "bar"
Foo::BAR == 'bar'
# => true

# Integer enum
class Bar
  include Ruby::Enum

  new :BAR, 1
  new :BAZ, 2
end

Bar::BAR
#  => "bar"
Bar::BAR == 1
# => true

Java introduced the concept of "typesafe enums", first as a design pattern and later as a first-class language construct. In Java, an Enum class defines a closed, valued set of instances of that class, rather than of a primitive type such as an int, and those instances have all the features of other objects, such as methods, fields, and type membership. Likewise, a TypesafeEnum class defines a valued set of instances of that class, rather than of a set of some other type.

Suit::CLUBS.is_a?(Suit)
# => true
Tarot::CUPS == 'Cups'
# => false

How is this different from java.lang.Enum?

Clunkier syntax

In Java 5+, you can define an enum in one line and instance-specific methods with a pair of braces.

enum CMYKColor {
  CYAN, MAGENTA, YELLOW, BLACK
}

enum Suit {
  CLUBS    { char pip() { return '♣'; } },
  DIAMONDS { char pip() { return '♦'; } },
  HEARTS   { char pip() { return '♥'; } },
  SPADES   { char pip() { return '♠'; } };

  abstract char pip();
}

With TypesafeEnum, instance-specific methods require extra parentheses, as shown above, and about the best you can do even for simple enums is something like:

class CMYKColor < TypesafeEnum::Base
  [:CYAN, :MAGENTA, :YELLOW, :BLACK].each { |c| new c }
end

No special switch/case support

The Java compiler will warn you if a switch statement doesn't include all instances of a Java enum. Ruby doesn't care whether you cover all instances of a TypesafeEnum, and in fact it doesn't care if your when statements include a mix of enum instances of different classes, or of enum instances and other things. (In some respects this latter is a feature, of course.)

No serialization support

The Java Enum class has special code to ensure that enum instances are deserialized to the existing singleton constants. This can be done with Ruby Marshal (by defining marshal_load) but it didn't seem worth the trouble, so a deserialized TypesafeEnum will not be identical to the original:

clubs2 = Marshal.load(Marshal.dump(Suit::CLUBS))
Suit::CLUBS.equal?(clubs2)
# => false

However, #==, #hash, etc. are Marshal-safe:

Suit::CLUBS == clubs2
# => true
clubs2 == Suit::CLUBS
# => true
Suit::CLUBS.hash == clubs2.hash
# => true

If this isn't enough, and the lack of object identity across marshalling is a problem, it could be added in a later version. (Pull requests welcome!)

No support classes

Java has Enum-specific classes like EnumSet and EnumMap that provide special high-performance, optimized versions of its collection interfaces. TypesafeEnum doesn't.

Enum classes are not closed

It's Ruby, so even though :new is private to each enum class, you can work around that in various ways:

Suit.send(:new, :JOKERS)
# => #<Suit:0x007fc9e44e4778 @key=:JOKERS, @value="jokers", @ord=4>

class Tarot
  new :MAJOR_ARCANA, 'Major Arcana'
end
# => #<Tarot:0x007f8513b39b20 @key=:MAJOR_ARCANA, @value="Major Arcana", @ord=4>

Suit.map(&:key)
# => [:CLUBS, :DIAMONDS, :HEARTS, :SPADES, :JOKERS]

Tarot.map(&:key)
# => [:CUPS, :COINS, :WANDS, :SWORDS, :MAJOR_ARCANA]

Contributing

Pull requests are welcome, but please make sure the tests pass, the code has 100% coverage, and the code style passes Rubocop. (The default rake task should check all of these for you.)

About

A gem that implements the typesafe enum pattern in Ruby

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages