Hiding global methods, a C++ example 32
Background
This is a continuation of this discussion. A few of the postings by GBGames have been swallowed so the context is somewhat lost. What follows is an excerpt from an email he sent me and some example C++ code that demonstrates making global methods “testable” in a sense. Or rather, making your code not use global methods during test.The Offending Method
Here is a method that GBGames has under test:void SDLHardwareLayer::initializeHardware()
{
const SDL_version * sdlVersion = SDL_Linked_Version();
if (sdlVersion->minor < 2 && sdlVersion->major < 2)
{
//Error message
}
else if (SDL_Init(SDL_INIT_VIDEO |
SDL_INIT_AUDIO |
SDL_INIT_NOPARACHUTE) < 0)
{
//Error message
}
else
{
m_screen = SDL_SetVideoMode(m_x, m_y, m_bitDepth, SDL_DOUBLEBUF);
if (NULL != m_screen)
{
SDL_WM_SetCaption(m_title.c_str(), 0);
}
}
}
This is how this code is organized:
So What’s Wrong?
One problem he notes is that the test code driving this production code causes windows to pop up during testing. Is this a problem? If it works, then it may be OK. However, you can remove that and also make test-ability a bit easier. You’ll be testing interactions and responses rather than directly using the library through its global functions.Global Functions Considered Harmful
First off, this code as written directly uses global functions. If you leave this code unchanged, then the only option you have to “fix” the problem of windows popping up is a link-seam (see Working Effective with Legacy Code, page 233-234. In a nutshell, a link seam will link different versions of a library, one for testing, one for actual execution. So you’ll need some “make fu” to get the link seam working in a manner that easily allows building for testing versus building for execution. Since we’re using C++ and we own this code, I chose a different route:- Change this code to use an object that uses the library
- That object implements an interface (a base class with all pure virtual methods)
- Change this code so that it acquires this object through a factory (I could have simply used constructor injection, but I’ve already written it using a configurable factory [really singleton] – but it would have been simpler if I had injected the interface)
- Created a test double that can be used when writing new test doubles making code maintenance a bit easier (mentioned in the other blog posting).
- Write test-method specific test doubles
Changing Code to Use Factory + Object w/virtual Methods
Rather than directly calling the various global SDL_* methods, e.g.:const SDL_version * sdlVersion = SDL_Linked_Version();
const SDL_version * sdlVersion = SdlLayerFactory::getInstance()->SDL_Linked_Version();
Base Interface
To make this work, I created a base interface representing all of the methods needed by the SDLHardwareLayer::initializeHardware:#pragma once
struct SDL_version;
class SdlLayerInstanceDelegator {
public:
virtual ~SdlLayerInstanceDelegator(void) = 0;
virtual const SDL_version *SDL_Linked_Version() = 0;
virtual int SDL_Init(int bitMask) = 0;
virtual void *SDL_SetVideoMode(int x, int y, int bitDepth, int mode) = 0;
virtual void SDL_WM_SetCaption(const char *title, int someIntValue) = 0;
protected:
SdlLayerInstanceDelegator();
private:
SdlLayerInstanceDelegator(const SdlLayerInstanceDelegator &rhs);
SdlLayerInstanceDelegator &operator = (const SdlLayerInstanceDelegator &);
};
Real Calls Global Methods
Getting rid of the direct call to the global method makes this work. However, you still need to call those global methods somewhere. That’s where the “real” version of the interface comes in. The real version of this class simply calls the global methods (code only, not showing header file right now):const SDL_version *RealSdlLayerInstanceDelegator::SDL_Linked_Version() {
return ::SDL_Linked_Version();
}
int RealSdlLayerInstanceDelegator::SDL_Init(int bitMask) {
return ::SDL_Init(bitMask);
}
void *RealSdlLayerInstanceDelegator::SDL_SetVideoMode(int x, int y, int bitDepth, int mode) {
return ::SDL_SetVideoMode(x, y, bitDepth, mode);
}
void RealSdlLayerInstanceDelegator::SDL_WM_SetCaption(const char *title, int someIntValue) {
::SDL_WM_SetCaption(title, someIntValue);
}
The Factory: Inversion of Control
The original code directly used global methods. We need to invert this relationship so that rather than depending directly on those methods, it depends on an interface. Then if it happens that it uses a “real” version, the underlying code will call the actual library. If, however, it is instead using a test double, it will not. It will do whatever the test double dictates.We cannot leave the decision of which object to talk to in the SdlHardwareLayer class. Instead, I’ve used a configurable factory (singleton). A test can configure the factory, call the method under test, and then reset the factory back after the test.
The SdlLayerFactory allows such use(again, code only, no header file):SdlLayerInstanceDelegator *SdlLayerFactory::instance = 0;
SdlLayerInstanceDelegator *SdlLayerFactory::getInstance() {
return instance;
}
SdlLayerInstanceDelegator *SdlLayerFactory::replaceInstance(SdlLayerInstanceDelegator *replacement) {
SdlLayerInstanceDelegator *original = instance;
instance = replacement;
return original;
}
Handling Growing Interfaces
One problem with “interfaces” in any language is that as you add methods to them, their implementations need to be updated. Since the SdlLayerInstanceDelegator class is not complete yet, this will case problems with test doubles. There are three ways to handle this problem: * Deal with it, as you add new methods to the interface, update all existing classes * Use a mocking library – I have not used any for C++, but if this were Java I’d use Mockito and if this were .Net I’d use Moq. * Create a base test double that implements all of the methods. All test doubles derive from that and only implement the methods needed for test.The last option does not fix the problem, it controls it. When new methods are added, you update one test double class and all other classes still work.
This may not sound like much of an issue, but in general you’ll have many small test doubles rather than a few large test doubles (or at least that’s the standard recommendation). Why? Because a small, focused test double is less likely to be broken. It also better expressed your intention.
Here is that test double (header file excluded again): TestDoubleSdlLayerInstanceDelegator.h
#pragma once
#include "SdlLayerInstanceDelegator.h"
class TestDoubleSdlLayerInstanceDelegator: public SdlLayerInstanceDelegator {
public:
TestDoubleSdlLayerInstanceDelegator();
virtual ~TestDoubleSdlLayerInstanceDelegator();
const SDL_version *SDL_Linked_Version();
int SDL_Init(int bitMask);
void *SDL_SetVideoMode(int x, int y, int bitDepth, int mode);
void SDL_WM_SetCaption(const char *title, int someIntValue);
};
TestDoubleSdlLayerInstanceDelegator.cpp
#include "TestDoubleSdlLayerInstanceDelegator.h"
TestDoubleSdlLayerInstanceDelegator::TestDoubleSdlLayerInstanceDelegator(){}
TestDoubleSdlLayerInstanceDelegator::~TestDoubleSdlLayerInstanceDelegator(){}
const SDL_version *TestDoubleSdlLayerInstanceDelegator::SDL_Linked_Version() {
return 0;
}
int TestDoubleSdlLayerInstanceDelegator::SDL_Init(int bitMask) {
return 0;
}
void *TestDoubleSdlLayerInstanceDelegator::SDL_SetVideoMode(int x, int y, int bitDepth, int mode) {
return 0;
}
void TestDoubleSdlLayerInstanceDelegator::SDL_WM_SetCaption(const char *title, int someIntValue){}
A Test
Finally, we need some tests and some test-specific test-doubles:#include <CppUTest/TestHarness.h>
#include "sdl.h"
#include "SdlLayerFactory.h"
#include "SdlHardwareLayer.h"
#include "TestDoubleSdlLayerInstanceDelegator.h"
TEST_GROUP(SdlHardwareLayerTest) {
virtual void setup() {
original = SdlLayerFactory::getInstance();
layer = new SdlHardwareLayer;
}
virtual void teardown() {
if(SdlLayerFactory::getInstance() != original)
delete SdlLayerFactory::getInstance();
SdlLayerFactory::replaceInstance(original);
delete layer;
}
SdlLayerInstanceDelegator *original;
SdlHardwareLayer *layer;
};
struct MajorMinorSdlhardwareLayer : public TestDoubleSdlLayerInstanceDelegator {
struct SDL_version v;
MajorMinorSdlhardwareLayer() {
v.major = 0;
v.minor = 0;
}
const SDL_version *SDL_Linked_Version() {
return &v;
}
};
TEST(SdlHardwareLayerTest, ItGeneratsErrorWithLowMajorMinorVersionNumber) {
SdlLayerFactory::replaceInstance(new MajorMinorSdlhardwareLayer);
try {
layer->initializeHardware();
FAIL("Should have thrown int value");
} catch (int value) {
LONGS_EQUAL(1, value);
}
}
struct InitFailingSdlHardwareLayer : public MajorMinorSdlhardwareLayer {
InitFailingSdlHardwareLayer () {
v.major = 3;
v.minor = 3;
}
int SDL_Init(int bitMask) {
return -1;
}
};
TEST(SdlHardwareLayerTest, ItGeneratesErrorWhenInitReturnsLessThan0) {
SdlLayerFactory::replaceInstance(new InitFailingSdlHardwareLayer);
try {
layer->initializeHardware();
FAIL("Should have thrown int value");
} catch (int value) {
LONGS_EQUAL(2, value);
}
}
Notice that there are two test doubles. Also notice that I create them as full (implicitly) inlined classes. Virtual methods and inline methods do not interact well. C++ will make a copy of the method bodies in every .o and increase link times. However, these test classes are only used in one place, so this really does not cause any problems.
In lieu of a mocking library, this is how I’d really write this code.The Final Big Picture
Here’s an image of the completed product:Summary
So is this overkill? Is using the actual SDL library causing problems? Is it OK for windows to appear during test? I’m neutral on that subject. The question I want to know is: are the tests working?- Do they add value or are they busy work.
- Do they allow me to make small, incremental steps towards a complete, working implementation
- Do they run fast enough?
- Will they run in any environment? (Can I run on a headless system?)
- Can I run multiple tests at the same time? (Not generally an issue, but it can be.)
If the SDL library is not causing a problem, then this might be overkill. However, on a long-lived project, this little amount of extra work will pay big dividends.
Oh, how long did this really take? Around 1.5 hours. But that included:- Starting a Windows XP VM
- Creating a new projec
- Setting up the link paths and include paths
- Creating the initial file and the necessary support to get it to compile
- Writing the additional classes
In fact, the actual work was probably < 30 minutes total. So while this might look big, in fact it is not. It’s nearly an idiom, so it’s mostly a matter of putting the hooks in place. So is full isolation from SDL worth 30 minutes?
Yes!
All your source files are belong to us
If this looks like a complete example, it is. I have this building and running in Visual Studio 2008. I took GBGame’s original method and got it to compile and link with a minimal set of additional source files. Then I made the structural changes to support test. And here’s all of the source code, file by file:sdl.h
#pragma once
extern "C" {
const int SDL_INIT_VIDEO = 1;
const int SDL_INIT_AUDIO = 2;
const int SDL_INIT_NOPARACHUTE = 4;
const int SDL_DOUBLEBUF = 8;
struct SDL_version {
int major;
int minor;
};
const SDL_version *SDL_Linked_Version();
int SDL_Init(int bitMask);
void *SDL_SetVideoMode(int x, int y, int bitDepth, int mode);
void SDL_WM_SetCaption(const char *title, int someIntValue);
};
sdl_implementation.cpp
#include "sdl.h"
const SDL_version *SDL_Linked_Version() {
return 0;
}
int SDL_Init(int bitMask) {
return - 1;
}
void *SDL_SetVideoMode(int x, int y, int bitDepth, int mode) {
return 0;
}
void SDL_WM_SetCaption(const char *title, int someIntValue){}
SdlHardwareLayer.h
#pragma once
#include <string>
class SdlHardwareLayer {
public:
SdlHardwareLayer();
virtual ~SdlHardwareLayer();
void initializeHardware();
private:
int m_x;
int m_y;
int m_bitDepth;
std::string m_title;
void *m_screen;
};
SdlHardwarelayer.cpp
#include "SdlHardwareLayer.h"
#include "sdl.h"
#include "SdlLayerFactory.h"
#include "SdlLayerInstanceDelegator.h"
SdlHardwareLayer::SdlHardwareLayer() {
}
SdlHardwareLayer::~SdlHardwareLayer() {
}
void SdlHardwareLayer::initializeHardware() {
const SDL_version * sdlVersion = SdlLayerFactory::getInstance()->SDL_Linked_Version();
if (sdlVersion->minor < 2 && sdlVersion->major < 2) {
throw 1;
}
else if (SdlLayerFactory::getInstance()->SDL_Init(SDL_INIT_VIDEO |
SDL_INIT_AUDIO |
SDL_INIT_NOPARACHUTE) < 0) {
throw 2;
}
else {
m_screen = SDL_SetVideoMode(m_x, m_y, m_bitDepth, SDL_DOUBLEBUF);
if (0 != m_screen) {
SDL_WM_SetCaption(m_title.c_str(), 0);
}
}
}
SdlLayerFactory.h
#pragma once
class SdlLayerInstanceDelegator;
class SdlLayerFactory
{
public:
static SdlLayerInstanceDelegator *getInstance();
static SdlLayerInstanceDelegator *replaceInstance(SdlLayerInstanceDelegator *replacement);
private:
static SdlLayerInstanceDelegator *instance;
SdlLayerFactory();
~SdlLayerFactory();
};
SdlLayerFactory.cpp
#include "SdlLayerFactory.h"
SdlLayerInstanceDelegator *SdlLayerFactory::instance = 0;
SdlLayerFactory::SdlLayerFactory(){}
SdlLayerFactory::~SdlLayerFactory(){}
SdlLayerInstanceDelegator *SdlLayerFactory::getInstance() {
return instance;
}
SdlLayerInstanceDelegator *SdlLayerFactory::replaceInstance(SdlLayerInstanceDelegator *replacement) {
SdlLayerInstanceDelegator *original = instance;
instance = replacement;
return original;
}
SdlLayerInstanceDelegator.h
#pragma once
struct SDL_version;
class SdlLayerInstanceDelegator {
public:
virtual ~SdlLayerInstanceDelegator(void) = 0;
virtual const SDL_version *SDL_Linked_Version() = 0;
virtual int SDL_Init(int bitMask) = 0;
virtual void *SDL_SetVideoMode(int x, int y, int bitDepth, int mode) = 0;
virtual void SDL_WM_SetCaption(const char *title, int someIntValue) = 0;
protected:
SdlLayerInstanceDelegator();
private:
SdlLayerInstanceDelegator(const SdlLayerInstanceDelegator &rhs);
SdlLayerInstanceDelegator &operator = (const SdlLayerInstanceDelegator &);
};
SdlLayerInstanceDelegator.cpp
#include "SdlLayerInstanceDelegator.h"
SdlLayerInstanceDelegator::SdlLayerInstanceDelegator(){}
SdlLayerInstanceDelegator::~SdlLayerInstanceDelegator(){}
TestDoubleSdlLayerInstanceDelegator.h
#pragma once
#include "SdlLayerInstanceDelegator.h"
class TestDoubleSdlLayerInstanceDelegator: public SdlLayerInstanceDelegator {
public:
TestDoubleSdlLayerInstanceDelegator();
virtual ~TestDoubleSdlLayerInstanceDelegator();
const SDL_version *SDL_Linked_Version();
int SDL_Init(int bitMask);
void *SDL_SetVideoMode(int x, int y, int bitDepth, int mode);
void SDL_WM_SetCaption(const char *title, int someIntValue);
};
TestDoubleSdlLayerInstanceDelegator.cpp
#include "TestDoubleSdlLayerInstanceDelegator.h"
TestDoubleSdlLayerInstanceDelegator::TestDoubleSdlLayerInstanceDelegator(){}
TestDoubleSdlLayerInstanceDelegator::~TestDoubleSdlLayerInstanceDelegator(){}
const SDL_version *TestDoubleSdlLayerInstanceDelegator::SDL_Linked_Version() {
return 0;
}
int TestDoubleSdlLayerInstanceDelegator::SDL_Init(int bitMask) {
return 0;
}
void *TestDoubleSdlLayerInstanceDelegator::SDL_SetVideoMode(int x, int y, int bitDepth, int mode) {
return 0;
}
void TestDoubleSdlLayerInstanceDelegator::SDL_WM_SetCaption(const char *title, int someIntValue){}
RealSdlLayerInstanceDelegator.h
#pragma once
#include "SdlLayerInstanceDelegator.h"
class RealSdlLayerInstanceDelegator : public SdlLayerInstanceDelegator
{
public:
RealSdlLayerInstanceDelegator();
virtual ~RealSdlLayerInstanceDelegator();
const SDL_version *SDL_Linked_Version();
int SDL_Init(int bitMask);
void *SDL_SetVideoMode(int x, int y, int bitDepth, int mode);
void SDL_WM_SetCaption(const char *title, int someIntValue);
};
RealSdlLayerInstanceDelegator.cpp
#include "RealSdlLayerInstanceDelegator.h"
#include "sdl.h"
RealSdlLayerInstanceDelegator::RealSdlLayerInstanceDelegator(){}
RealSdlLayerInstanceDelegator::~RealSdlLayerInstanceDelegator(){}
const SDL_version *RealSdlLayerInstanceDelegator::SDL_Linked_Version() {
return ::SDL_Linked_Version();
}
int RealSdlLayerInstanceDelegator::SDL_Init(int bitMask) {
return ::SDL_Init(bitMask);
}
void *RealSdlLayerInstanceDelegator::SDL_SetVideoMode(int x, int y, int bitDepth, int mode) {
return ::SDL_SetVideoMode(x, y, bitDepth, mode);
}
void RealSdlLayerInstanceDelegator::SDL_WM_SetCaption(const char *title, int someIntValue) {
::SDL_WM_SetCaption(title, someIntValue);
}
SdlHardwareLayerTest.h
#include <CppUTest/TestHarness.h>
#include "sdl.h"
#include "SdlLayerFactory.h"
#include "SdlHardwareLayer.h"
#include "TestDoubleSdlLayerInstanceDelegator.h"
TEST_GROUP(SdlHardwareLayerTest) {
virtual void setup() {
original = SdlLayerFactory::getInstance();
layer = new SdlHardwareLayer;
}
virtual void teardown() {
if(SdlLayerFactory::getInstance() != original)
delete SdlLayerFactory::getInstance();
SdlLayerFactory::replaceInstance(original);
delete layer;
}
SdlLayerInstanceDelegator *original;
SdlHardwareLayer *layer;
};
struct MajorMinorSdlhardwareLayer : public TestDoubleSdlLayerInstanceDelegator {
struct SDL_version v;
MajorMinorSdlhardwareLayer() {
v.major = 0;
v.minor = 0;
}
const SDL_version *SDL_Linked_Version() {
return &v;
}
};
TEST(SdlHardwareLayerTest, ItGeneratsErrorWithLowMajorMinorVersionNumber) {
SdlLayerFactory::replaceInstance(new MajorMinorSdlhardwareLayer);
try {
layer->initializeHardware();
FAIL("Should have thrown int value");
} catch (int value) {
LONGS_EQUAL(1, value);
}
}
struct InitFailingSdlHardwareLayer : public MajorMinorSdlhardwareLayer {
InitFailingSdlHardwareLayer () {
v.major = 3;
v.minor = 3;
}
int SDL_Init(int bitMask) {
return -1;
}
};
TEST(SdlHardwareLayerTest, ItGeneratesErrorWhenInitReturnsLessThan0) {
SdlLayerFactory::replaceInstance(new InitFailingSdlHardwareLayer);
try {
layer->initializeHardware();
FAIL("Should have thrown int value");
} catch (int value) {
LONGS_EQUAL(2, value);
}
}
RunAllTests.cpp
#include <CppUTest/CommandLineTestRunner.h>
int main(int ac, char **av) {
return CommandLineTestRunner::RunAllTests(ac, av);
}
Tighter Ruby Methods with Functional-style Pattern Matching, Using the Case Gem 145
Ruby doesn’t have overloaded methods, which are methods with the same name, but different signatures when you consider the argument lists and return values. This would be somewhat challenging to support in a dynamic language with very flexible options for method argument handling.
You can “simulate” overloading by parsing the argument list and taking different paths of execution based on the structure you find. This post discusses how pattern matching, a hallmark of functional programming, gives you powerful options.
First, let’s look at a typical example that handles the arguments in an ad hoc fashion. Consider the following Person
class. You can pass three arguments to the initializer, the first_name
, the last_name
, and the age
. Or, you can pass a hash using the keys :first_name
, :last_name
, and :age
.
require "rubygems"
require "spec"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
arg = args[0]
if arg.kind_of? Hash # 1
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
else
@first_name = args[0]
@last_name = args[1]
@age = args[2]
end
end
end
describe "Person#initialize" do
it "should accept a hash with key-value pairs for the attributes" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
end
The condition on the # 1
comment line checks to see if the first argument is a Hash
. If so, the attribute’s values are extracted from it. Otherwise, it is assumed that three arguments were specified in a particular order. They are passed to #initialize
in a three-element array. The two rspec examples exercise these behaviors. For simplicity, we ignore some more general cases, as well as error handling.
Another approach that is more flexible is to use duck typing, instead. For example, we could replace the line with the # 1
comment with this line:
if arg.respond_to? :has_key?
There aren’t many objects that respond to #has_key?
, so we’re highly confident that we can use [symbol]
to extract the values from the hash.
This implementation is fairly straightforward. You’ve probably written code like this yourself. However, it could get complicated for more involved cases.
Pattern Matching, a Functional Programming Approach
Most programming languages today have switch
or case
statements of some sort and most have support for regular expression matching. However, in functional programming languages, pattern matching is so important and pervasive that these languages offer very powerful and convenient support for pattern matching.
Fortunately, we can get powerful pattern matching, typical of functional languages, in Ruby using the Case gem that is part of the MenTaLguY’s Omnibus Concurrency library. Omnibus
provides support for the hot Actor model of concurrency, which Erlang has made famous. However, it would be a shame to restrict the use of the Case
gem to parsing Actor messages. It’s much more general purpose than that.
Let’s rework our example using the Case gem.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
case args
when Case[Hash] # 1
arg = args[0]
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
else
@first_name = args[0]
@last_name = args[1]
@age = args[2]
end
end
end
describe "Person#initialize" do
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a has with :first_name => fn, :last_name => ln, and :age => age" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
end
We require the case
gem, which puts the #===
method on steroids. In the when
statement in #initialize
, the expression when Case[Hash]
matches on a one-element array where the element is a Hash
. We extract the key-value pairs as before. The else
clause assumes we have an array for the arguments.
So far, this is isn’t very impressive, but all we did was to reproduce the original behavior. Let’s extend the example to really exploit some of the neat features of the Case
gem’s pattern matching. First, let’s narrow the allowed array values.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
case args
when Case[Hash] # 1
arg = args[0]
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
when Case[String, String, Integer]
@first_name = args[0]
@last_name = args[1]
@age = args[2]
else
raise "Invalid arguments: #{args}"
end
end
end
describe "Person#initialize" do
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a has with :first_name => fn, :last_name => ln, and :age => age" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should not accept an array unless it is a [String, String, Integer]" do
lambda { person = Person.new "Dean", "Wampler", "39" }.should raise_error(Exception)
end
end
The new expression when Case[String, String, Integer]
only matches a three-element array where the first two arguments are strings and the third argument is an integer, which are the types we want. If you use an array with a different number of arguments or the arguments have different types, this when
clause won’t match. Instead, you’ll get the default else
clause, which raises an exception. We added another rspec example to test this condition, where the user’s age was specified as a string instead of as an integer. Of course, you could decide to attempt a conversion of this argument, to make your code more “forgiving” of user mistakes.
Similarly, what happens if the method supports default values some of the parameters. As written, we can’t support that option, but let’s look at a slight variation of Person#initialize
, where a hash of values is not supported, to see what would happen.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize first_name = "Bob", last_name = "Martin", age = 29
case [first_name, last_name, age]
when Case[String, String, Integer]
@first_name = first_name
@last_name = last_name
@age = age
else
raise "Invalid arguments: #{first_name}, #{last_name}, #{age}"
end
end
end
def check person, expected_fn, expected_ln, expected_age
person.first_name.should == expected_fn
person.last_name.should == expected_ln
person.age.should == expected_age
end
describe "Person#initialize" do
it "should require a first name (string), last name (string), and age (integer) arguments" do
person = Person.new "Dean", "Wampler", 39
check person, "Dean", "Wampler", 39
end
it "should accept the defaults for all parameters" do
person = Person.new
check person, "Bob", "Martin", 29
end
it "should accept the defaults for the last name and age parameters" do
person = Person.new "Dean"
check person, "Dean", "Martin", 29
end
it "should accept the defaults for the age parameter" do
person = Person.new "Dean", "Wampler"
check person, "Dean", "Wampler", 29
end
it "should not accept the first name as a symbol" do
lambda { person = Person.new :Dean, "Wampler", "39" }.should raise_error(Exception)
end
it "should not accept the last name as a symbol" do
end
it "should not accept the age as a string" do
lambda { person = Person.new "Dean", "Wampler", "39" }.should raise_error(Exception)
end
end
We match on all three arguments as an array, asserting they are of the correct type. As you might expect, #initialize
always gets three parameters passed to it, including when default values are used.
Let’s return to our original example, where the object can be constructed with a hash or a list of arguments. There are two more things (at least …) that we can do. First, we’re not yet validating the types of the values in the hash. Second, we can use the Case
gem to impose constraints on the values, such as requiring non-empty name strings and a positive age.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
case args
when Case[Hash]
arg = args[0]
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
when Case[String, String, Integer]
@first_name = args[0]
@last_name = args[1]
@age = args[2]
else
raise "Invalid arguments: #{args}"
end
validate_name @first_name, "first_name"
validate_name @last_name, "last_name"
validate_age
end
protected
def validate_name name, field_name
case name
when Case::All[String, Case.guard {|s| s.length > 0 }]
else
raise "Invalid #{field_name}: #{first_name}"
end
end
def validate_age
case @age
when Case::All[Integer, Case.guard {|n| n > 0 }]
else
raise "Invalid age: #{@age}"
end
end
end
describe "Person#initialize" do
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a has with :first_name => fn, :last_name => ln, and :age => age" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should not accept an array unless it is a [String, String, Integer]" do
lambda { person = Person.new "Dean", "Wampler", "39" }.should raise_error(Exception)
end
it "should not accept a first name that is a zero-length string" do
lambda { person = Person.new "", "Wampler", 39 }.should raise_error(Exception)
end
it "should not accept a first name that is not a string" do
lambda { person = Person.new :Dean, "Wampler", 39 }.should raise_error(Exception)
end
it "should not accept a last name that is a zero-length string" do
lambda { person = Person.new "Dean", "", 39 }.should raise_error(Exception)
end
it "should not accept a last name that is not a string" do
lambda { person = Person.new :Dean, :Wampler, 39 }.should raise_error(Exception)
end
it "should not accept an age that is less than or equal to zero" do
lambda { person = Person.new "Dean", "Wampler", -1 }.should raise_error(Exception)
lambda { person = Person.new "Dean", "Wampler", 0 }.should raise_error(Exception)
end
it "should not accept an age that is not an integer" do
lambda { person = Person.new :Dean, :Wampler, "39" }.should raise_error(Exception)
end
end
We have added validate_name
and validate_age
methods that are invoked at the end of #initialize
. In validate_name
, the one when
clause requires “all” the conditions to be true, that the name is a string and that it has a non-zero length. Similarly, validate_age
has a when
clause that requires age
to be a positive integer.
Final Thoughts
So, how valuable is this? The code is certainly longer, but it specifies and enforces expected behavior more precisely. The rspec examples verify the enforcement. It smells a little of static typing, which is good or bad, depending on your point of view. ;)
Personally, I think the conditional checks are a good way to add robustness in small ways to libraries that will grow and evolve for a long time. The checks document the required behavior for code readers, like new team members, but of course, they should really get that information from the tests. ;) (However, it would be nice to extract the information into the rdocs
.)
For small, short-lived projects, I might not worry about the conditional checks as much (but how many times have those “short-lived projects” refused to die?).
You can read more about Omnibus
and Case
in this InfoQ interview with MenTaLguY. I didn’t discuss using the Actor model of concurrency, for which these gems were designed. For an example of Actors using Omnibus, see my Better Ruby through Functional Programming presentation or the Confreak’s video of an earlier version of the presentation I gave at last year’s RubyConf.