This recipe implements a design pattern useful for performing an object-oriented case analysis for a particular object (or a collection of objects as well). Essentially, it is an alternative to complex if-then-else or switches. Modelling each case with a particular class, the Concrete Class Finder searches for an appropriate case/class that applies to the given object/s. Once found, this class can be used in an homogeneous way, independently of the object/s previously considered.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | # concrecte_class_finder.py
# An object-oriented alternative to complex if-then-else or switches
# Tested under Python 2.7 and 2.6.6 only
#
# Copyright (C) 2011 by Lucio Santi <lukius at gmail dot com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
__author__ = 'Lucio Santi <lukius at gmail dot com>'
__version__ = '1.0'
__all__ = ['ConcreteClassFinder',
'SubclassNotFoundException',
'ClassNotFoundException',
'MultipleSubclassesFoundException',
'MultipleClassesFoundException']
###############################################################################
class ConcreteClassFinder(object):
"""An object that searches for a suitable class for handling a single
object or a collection of them. This search can be performed over the leaf
subclasses of a base class as well as over an iterable object containing
classes (e.g., a list).
Each of the classes inspected should provide a user-defined class method
that indicates whether the respective class can correctly handle the
desired object/s. By default, this method name is 'can_handle'/
'can_handle_objects'.
Actions can be specified for exceptional cases where no classes or multiple
classes are found.
"""
@classmethod
def __default_testing_method(cls):
return 'can_handle'
@classmethod
def __default_testing_method_with_args(cls):
return 'can_handle_objects'
@classmethod
def __class_handles(cls, klass, method_name, args):
method = getattr(klass, method_name)
try:
return method(args)
except Exception:
return False
@classmethod
def find_subclass(cls, base_class, object, if_none = None, if_many = None, method = None):
if( method is None ):
method = cls.__default_testing_method()
return cls.__pre_find_subclass(base_class, method, object, if_none, if_many)
@classmethod
def find_subclass_with_args(cls, base_class, args, if_none = None, if_many = None, method = None):
if( method is None ):
method = cls.__default_testing_method_with_args()
return cls.__pre_find_subclass(base_class, method, args, if_none, if_many)
@classmethod
def __pre_find_subclass(cls, base_class, method, args, if_none = None, if_many = None):
def default_action_if_none():
raise SubclassNotFoundException(base_class, method, args)
def default_action_if_many(candidates):
raise MultipleSubclassesFoundException(base_class, method, args, candidates)
if( if_none is None ):
if_none = default_action_if_none
if( if_many is None ):
if_many = default_action_if_many
candidates = [klass for klass in LeafSubclassRetriever(base_class).value() if hasattr(klass, method)]
return cls.__find_class(candidates, method, args, if_none, if_many)
@classmethod
def find_class(cls, classes, object, if_none = None, if_many = None, method = None):
if( method is None ):
method = cls.__default_testing_method()
return cls.__pre_find_class(classes, method, object, if_none, if_many)
@classmethod
def find_class_with_args(cls, classes, args, if_none = None, if_many = None, method = None):
if( method is None ):
method = cls.__default_testing_method_with_args()
return cls.__pre_find_class(classes, method, args, if_none, if_many)
@classmethod
def __pre_find_class(cls, classes, method, args, if_none = None, if_many = None):
def default_action_if_none():
raise ClassNotFoundException(classes, method, args)
def default_action_if_many(candidates):
raise MultipleClassesFoundException(classes, method, args, candidates)
if( if_none is None ):
if_none = default_action_if_none
if( if_many is None ):
if_many = default_action_if_many
return cls.__find_class(classes, method, args, if_none, if_many)
@classmethod
def __find_class(cls, classes, method, args, action_if_none, action_if_many):
suitable_classes = filter(lambda klass: cls.__class_handles(klass, method, args), classes)
if( len(suitable_classes) < 1 ): return action_if_none()
if( len(suitable_classes) > 1 ): return action_if_many(suitable_classes)
return suitable_classes[0]
###############################################################################
###############################################################################
class LeafSubclassRetriever(object):
def __init__(self, base_class):
self.base_class = base_class
def value(self):
direct_subclasses = self.base_class.__subclasses__()
leaf_subclasses = list()
for klass in direct_subclasses:
if( len(klass.__subclasses__()) > 0 ):
leaf_subclasses += LeafSubclassRetriever(klass).value()
else:
leaf_subclasses.append(klass)
return leaf_subclasses
###############################################################################
###############################################################################
class ClassFindingException(Exception):
def __init__(self, method, args, candidates = None):
self.method = method
self.arguments = args
self.candidates = candidates
def __str__(self):
raise NotImplementedError('subclass responsibility')
class SubclassNotFoundException(ClassFindingException):
def __init__(self, base_class, method, args):
super(SubclassNotFoundException, self).__init__(method, args)
self.base_class = base_class
def __str__(self):
return 'base class %s has no subclass satisfying %s(%s)' \
% (self.base_class.__name__, str(self.method), str(self.arguments))
class ClassNotFoundException(ClassFindingException):
def __init__(self, classes, method, args):
super(ClassNotFoundException, self).__init__(method, args)
self.classes = classes
def __str__(self):
return '%s does not contain any class satisfying %s(%s)' \
% (str(self.classes), str(self.method), str(self.arguments))
class MultipleSubclassesFoundException(ClassFindingException):
def __init__(self, base_class, method, args, candidates):
super(MultipleSubclassesFoundException, self).__init__(method, args, candidates)
self.base_class = base_class
def __str__(self):
return 'base class %s has multiple subclasses satisfying %s(%s): %s' \
% (self.base_class.__name__, str(self.method), str(self.arguments), str(self.candidates))
class MultipleClassesFoundException(ClassFindingException):
def __init__(self, classes, method, args, candidates):
super(MultipleSubclassesFoundException, self).__init__(method, args, candidates)
self.classes = classes
def __str__(self):
return '%s contains multiple classes satisfying %s(%s): %s' \
% (str(self.classes), str(self.method), str(self.arguments), str(self.candidates))
###############################################################################
|
In order to shed more light on the subject, consider the simple example shown below. Suppose we have different kinds of geometric shapes, and we need to develop some code for calculating their respective areas:
class Circle(object):
def __init__(self, center, radius):
self.center = center
self.radius = radius
class Rectangle(object):
def __init__(self, p1, p2):
self.p1 = p1
self.p2 = p2
class Triangle(object):
def __init__(self, p1, p2, p3):
self.p1 = p1
self.p2 = p2
self.p3 = p3
Additional behavior is intentionally left aside for the sake of simplicity, as well as a common Shape superclass. Being the area a nonessential matter for a shape (in the sense that the area is not a key concept to model a shape), it would be wise (and a good programming practice too) to keep this behavior outside the shape itself, and thus decoupling the concept from the actual representation. Using the Concrete Class Finder, we can have something like this:
from concrete_class_finder import *
from math import pi
from collections import namedtuple
Point = namedtuple('Point', 'x y')
class AreaCalculator(object):
# This method shows a typical use of the Concrete Class Finder, encapsulating
# its use and making it completely transparent to the user.
@classmethod
def for_shape(cls, shape):
suitable_class = ConcreteClassFinder.find_subclass(cls, shape)
return suitable_class(shape)
def __init__(self, shape):
self.shape = shape
def value(self):
raise NotImplementedError('subclass responsibility')
class CircleAreaCalculator(AreaCalculator):
# This method is crucial: the Concrete Class Finder needs it to find out
# if the shape under observation is a Circle.
@classmethod
def can_handle(cls, shape):
return isinstance(shape, Circle)
# Once here, we are certain that self.shape is indeed a Circle.
def value(self):
return pi * self.shape.radius**2
class RectangleAreaCalculator(AreaCalculator):
@classmethod
def can_handle(cls, shape):
return isinstance(shape, Rectangle)
def value(self):
base = self.shape.p2.x - self.shape.p1.x
height = self.shape.p1.y - self.shape.p2.y
return base * height
class TriangleAreaCalculator(AreaCalculator):
@classmethod
def can_handle(cls, shape):
return isinstance(shape, Triangle)
def value(self):
a_x, a_y = self.shape.p1.x, self.shape.p1.y
b_x, b_y = self.shape.p2.x, self.shape.p2.y
c_x, c_y = self.shape.p3.x, self.shape.p3.y
return abs( a_x * (b_y - c_y) + b_x * (c_y - a_y) + c_x * (a_y - b_y) ) / 2
The following code exhibits how all this can be effectively used:
>>> circle = Circle(Point(0,0), 10)
>>> rectangle = Rectangle(Point(0, 10), Point(5, 0))
>>> triangle = Triangle(Point(0,0), Point(0,20), Point(30,20))
>>> shapes = [circle, rectangle, triangle]
>>> for shape in shapes: print AreaCalculator.for_shape(shape).value()
...
314.159265359
50
300
>>> NullShape = namedtuple('NullShape', '')
>>> AreaCalculator.for_shape(NullShape()).value()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "concrete_class_finder_example.py", line 26, in for_shape
suitable_class = ConcreteClassFinder.find_subclass(cls, shape)
File "concrete_class_finder.py", line 67, in find_subclass
return cls.__pre_find_subclass(base_class, method, object, if_none, if_many)
File "concrete_class_finder.py", line 88, in __pre_find_subclass
return cls.__find_class(candidates, method, args, if_none, if_many)
File "concrete_class_finder.py", line 120, in __find_class
if( len(suitable_classes) < 1 ): return action_if_none()
File "concrete_class_finder.py", line 78, in default_action_if_none
raise SubclassNotFoundException(base_class, method, args)
concrete_class_finder.SubclassNotFoundException: base class AreaCalculator has no subclass satisfying can_handle(NullShape())
This last statement shows what happens if an object doesn't have a case/class handling it. Actions can be specified for these scenarios (and for multiple classes handling it as well), being exception raising the default behavior.