-
any? [{ |obj| block }] → true or false
means could either accept 0 or 1{ |obj| block }
block. -
send(symbol [, args...]) → obj
means could either accept 0 or many argumetns. if symobol method accept block, then may also appen that block to it, e.g.define_method
. -
Complex(x[, y]) → numeric
means could either accept 0 or 1y
agrument. -
exec([env,] command... [,options])
means it amy specify theenv
or ignore it soenv
set by default. -
open(path [, mode [, perm]] [, opt]) → io or nil
accepts either 0 or 1mode
, oropt
argument. Ifmode
specified, thenperm
may required. If 0 formode
, then rest arguments are asopt
. -
spawn([env,] command... [,options]) → pid
:pid = spawn({"FOO"=>"BAR"}, command, :unsetenv_others=>true) # FOO only
-
[{block}]
means could either apply 0 or 1 block. -
[{block}...]
means could either apply 0 or many blocks.
###Input collection
-
Coerce objects into the roles we need them to play.
-
Reject unexpected values which cannot play the needed roles.
-
Substitute known-good objects for unacceptable inputs.
###Type of conversions
- Ruby doesn't call explicit conversions for core classes. But when we use string interpolation, Ruby implicitly uses
#to_s
ex:"String interpolation #{Time.now}"
.
Method | Target Class | Type | Notes |
---|---|---|---|
#to_a | Array | Explicit | |
#to_ary | Array | Implicit | |
#to_c | Complex | Explicit | |
#to_enum | Enumerator | Explicit | |
#to_h | Hash | Explicit | Introduced in Ruby2.0 |
#to_hash | Hash | Implicit | |
#to_i | Integer | Explicit | |
#to_int | Integer | Implicit | |
#to_io | IO | Implicit | |
#to_open | IO | Implicit | Used by IO.open |
#to_path | String | Implicit | |
#to_proc | Proc | Implicit | |
#to_r | Rational | Explicit | |
#to_regexp | Regexp | Implicit | Used by Regexp.try_convert |
#to_s | String | Explicit | |
#to_str | String | Implicit | |
#to_sym | Symbol | Implicit |
def set_centrifuge_speed(new_rpm)
new_rpm = new_rpm.to_int
puts "Adjusting {new_rpm} RPM"
end
- Here the implicit conversion call serves as an assertion, ensuring the method receives an argument which is either an Integer or an Object with a well-defined conversion to Integer.
class Point
attr_reader :x, :y, :name
def initialize(x, y, name=nil)
@x, @y, @name = x, y, name
end
def to_coords
[x, y]
end
end
def method(point)
point.respond_to?(:to_coords) ? point.to_coords : point.to_ary
end
- Provide your own conversion protocol
:to_coords
withrespond_to?
check.
-
Use Ruby's capitalized conversion functions, such as
Integer()
andArray()
to ensure input must be an array-convertible data or raise error. Those methods such asKernel#Array()
has two advantages:-
They are idempotent. Calling it with an argument of the target type will simply return the unmodified argument.
-
They can convert a wider array of input types than the equivalent
#to_*
methods.
-
items = ["a", 1, "b", 2, "c", 3]
Hash[*items]
# => {"a"=>1, "b"=>2, "c"=>3}
Hash[]
will produce a Hash where the first argument is a key, the second is a value, the third is a key, the fourth is its value, and so on.
Pair = Struct.new(:a, :b) do
def to_ary
[a, b]
end
end
-
The conversion function should be simple because we will call it a lot. And it should be idempotent.
-
We may group the conversion functions to a module and set
module_function
for them:
module A
def b; end
module_function :b
end
class B
include A
end
A.singleton_methods true
# => [:b]
B.new.private_methods.include? :b
# => true
module_function
set two things for the method. First, it marks all following methods as private. Second, it makes the methods available as singleton methods on the module.
class TrafficLight
def signal
case @state
when "Stop" then turn_on_lamp(:red)
when "Caution"
turn_on_lamp(:yellow)
ring_warning_bell
when "proceed" then turn_on_lamp(:green) end
end
end
# 1. Move String to object. Reuse it as Constant.
State = Struct.new(:name) do
def to_s
name
end
end
STOP = State.new("stop")
PROCEDE = State.new("procede")
VALIS_STATES = [STOP, PROCEDE]
class TrafficLight
def change_to(state)
raise ArgumentError unless VALID_STATES.include?(state)
@state = state
end
def signal
case @state
when STOP then p @state # use#to_s to show current state.
end
end
end
# 2. Instead of instantiate object for each string,
# inherit and provides method for it.
class State; end
class Stop < State
def next_state; Procede.new; end
def color; "red"; end
def signal(light)
light.turn_on_lamp(color.to_sym)
end
end
class Procede < State
def next_state; Stop.new; end
def color; "green"; end
def signal(traffic_light)
traffic_light.turn_on_lamp(color.to_sym)
end
end
class TrafficLight
def turn_on_lamp(color)
# ...
end
def signal
@state.signal(self)
end
end
-
When dealing with string (or Symbol) values coming from the "borders" of our code, it can be tempting to simply ensure that they are in the expected set of valid values, and leave it at that.
-
However, representing this concept as a class or set of classes can not only make the code less error-prone; it can clarify our understanding of the problem, and improve the design of all the classes which deal with that concept.
class Benchmark
def log
case @sink
when Cinch::Bot
@sink.dispatch(:log_info, message)
else
@sink << message
end
end
end
# 1. Improvement by warp it to an adapter. Now common interface << be called.
class IrcBot
def initialize(bot);
@bot = bot;
end
def <<(message)
@bot.dispatch(:log_info, message)
end
end
class Benchmark
def initialize(sink)
@case = case sink
when Cinch::Bot then IrcBot.new(sink)
end
end
def log
# ...
@sink << message
end
end
# 2. Above IrcBot may not support other function from Cinch::Bot.
# This may cause problem for other functionality we want to achive in target class.
# So we instead add a transparent adapter for it - Use delegator.
require 'delegate'
class Benchmark
class IrcBotSink < DelegateClass(Cinch::Bot)
def initialize(bot);
@bot = bot;
end
def <<(message)
@bot.dispatch(:log_info, nil, message)
end
end
def initialize(sink)
@sink = case sink
when Cinch::Bot then IrcBotSink.new(sink)
else sink
end
end
end
-
One of the purposes of a constructor is to establish an object's invariant: a set of properties which should always hold true for that object.
-
Decisively rejecting unusable values at the entrance to our classes can make our code more robust, simplify the internals, and provide valuable documentation to other developers.
# Instead of directly build a instance variable @hire_date,
# use hire_date setter method to filter out unworkable cases.
# This filter method makes constructor still clean, and seperate
# the logic out from building variables and filtering unworkable
# condition for that variable.
def initialize(hire_date)
self.hire_date = hire_date
end
def hire_date=(new_hire_date)
raise TypeError, "Invalid hire date" unless
new_hire_date.is_a?(Date)
@hire_date = new_hire_date
end
-
Instead of use
[]
operator to check keys, the#fetch
will raise KeyError rather thannil
if key does not present and block does not provide. This distinguish two conditions which are key presented but value isnil
or missing key so returnnil
. -
fetch(key [, default] ) → obj
if default argument is given, then return default. -
fetch(key) {| key | block } → obj
If the optional code block is specified, then that will be run and its result returned.
# add block to custom fetch behavior of missing key.
def add_user(attributes)
login = attributes.fetch(:login)
password = attributes.fetch(:password) do
raise KeyError, "Password (or false) must be supplied" end
# ...
end
- Because
#fetch
only executes and returns the value of the given block if the specified key is missing, not just falsey.
# if key does not present, then create logger object which accept STDOUT as output source.
logger = options.fetch(:logger) { Logger.new($stdout) }
if logger == false
logger = Logger.new('/dev/null')
end
-
Before we execute a method, we should early return if the input case found should be guarded.
-
Besides directly early return, we can wrap that special case as an object and behave like normal case but support all method calls with properly return result. (consider a session may include guest user, valid user, or invalid input.)
-
We can extend this approach for presenting do-nothing object as customed null object.
-
Sometimes, the object has many properties, but that input object may missing values for the property and cause method crashed. In that case we can supply a benign value - a known - good object that stands in for a missing input.
-
Compare
4.
and5.
, null object is for the semantically null behavior and benign values is for supplying a valid object which missing some unnecessary properties for the object.
# Mock all valid input's method but return with properly result.
# So all other functions related to session can focus on its core function
# instead of guarding special cases with many effort.
class GuestUser
def initialize(session)
@session = session
end
def name
"Anonymous"
end
def authenticated?
false
end
def has_role?(role)
false
end
def visible_listings
Listing.publicly_visible
end
def last_seen_online=(time)
# NOOP
end
def cart
SessionCart.new(@session)
end
end
def current_user
if session[:user_id]
User.find(session[:user_id])
else
# special case as object.
GuestUser.new(session)
end
end
# if current_user
# @listings = current_user.visible_listings
# else
# @listings = Listing.publicly_visible
# end
# Now it can reduce if/else conditions of following code to just one line.
@listings = current_user.visible_listings
-
There are many other ways to make communicate missing-but-necessary values more clearly.
-
Symbol is a unique and reusable object in system. Instead of provide a string for missing-but-necessary values, symbol are more light-weight and easier to check.
-
Set
nil
as default missing-but-necessary values usually cannot help much to identify the information of that missing field.
-
Sometimes your method accepted various but similar parameters. Instead of checking those extra parameters, we can wrap the essential parameters as an object and mutate (through the inheritance/mixin/delegation for that class/module) it as we need.
-
By this mean, it reduces conditional tests for parameters, and properly define the type of each similar input.
-
In further, you may define specific implementation for same behaviour(method).
Point = Struct.new(:x, :y) do # ...
# specific implementation
def draw_on(map)
# ...
end
end
class Map
def draw_point(point)
point.draw_on(self)
end
def draw_line(point1, point2)
# now the detail of implementation is based on input object.
point1.draw_on(self)
point2.draw_on(self)
# draw line connecting points...
end
end
# 2 Use delegation to extend extra inputs.
class FuzzyPoint < SimpleDelegator
def initialize(point, fuzzy_radius)
super(point)
@fuzzy_radius = fuzzy_radius
end
def draw_on(map)
super # draw the point
# draw a circle around the point...
end
def to_hash
super.merge(fuzzy_radius: @fuzzy_radius)
end
end
map = Map.new
# extra input @fuzzy_radius has merged to parameters hash.
p1 = FuzzyPoint.new(StarredPoint.new(23, 32), 100)
map.draw_point(p1)
- If you define a general parameter object, then it must have some parameters are not necessary for intialization. But user may want to add the values for it. In that case,
yield
parameter object to let user provides a block and able to change values for those parameters.
# magnitude and name parameters are not necessary.
Point < Struct.new(:x, :y, :name, :magnitude) do
def initialize(x, y, name='', magnitude=5)
super(x, y, name, magnitude)
end
def magnitude=(magnitude)
raise ArgumentError unless (1..20).include?(magnitude)
super(magnitude)
end
# ...
end
class Map
def draw_point(point_or_x, y=:y_not_set_in_draw_point)
point = point_or_x.is_a?(Integer) ? Point.new(point_or_x, y) : point_or_x
yield(point) if block_given?
point.draw_on(self)
end
def draw_starred_point(x, y, &point_customization)
draw_point(StarredPoint.new(x, y), &point_customization)
end
# ...
end
# Now use can dynamically set the parameters of magnitude and name.
map.draw_point(7, 9) do |point|
point.magnitude = 3
end
map.draw_starred_point(18, 27) do |point|
point.name = "home base"
end
- Combine with delegation, we can delegate the object when the extra parameter's setter method being called.
require 'delegate'
# general paramtere object
class PointBuilder < SimpleDelegator
def initialize(point)
super(point)
end
# The setter also change the parameter object from Point to FuzzyPoint.
def fuzzy_radius=(fuzzy_radius)
# __setobj__ is how we replace the wrapped object in a # SimpleDelegator
__setobj__(FuzzyPoint.new(point, fuzzy_radius));
end
def point
__getobj__
end
end
map.draw_starred_point(7, 9) do |point|
point.name = "gold buried here"
point.magnitude = 15
# change Point object to FuzzyPoint
point.fuzzy_radius = 50
end
# The problem left now is:
# we can not sure which class is the exactly parameter object.
# But it must mixed in those classes(FuzzyPoint, Point).
-
Different clients of a method want a potential edge case to be handled in different ways. For instance, some callers of a method that deletes files may want to be notified when they try to delete a file that doesn't exist. Others may want to ignore missing files.
-
Sometimes you need user-defined policy for your edge case of input. To achieve this, insert customed policy from input(ex: hash with proc as value) instead directly set default policy.
-
If possible, data should be filtered by the policy before entering the method.
def delete_files(files, options={})
# if cannot find user-defined policy, then return default policy.
error_policy = options.fetch(:on_error) { ->(file, error) { raise error } }
symlink_policy = options.fetch(:on_symlink) { ->(file) { File.delete(file) } }
files.each do |file|
begin
if File.symlink?(file)
symlink_policy.call(file)
else
File.delete(file)
end
rescue => error
error_policy.call(file, error)
end
end
end