Welcome, guest | Sign In | My Account | Store | Cart

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.

Python, 188 lines
  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.