Tuesday, November 14, 2017

Concrete Functionality in Abstract Methods: Planning for Extension

While playing around with some ideas for a fairly complex class-library for a project at work, I hit on an idea that I wanted to explore and share. The back-story is that the class-library contains a number of abstract base classes that define interface contracts, and most of those ABCs get used in defining concrete classes that are focused on integration with one of several third-party system APIs. Right now, there's four or possibly five such third-party APIs, and between them there are at least two different underlying connection- and usage-styles — implementing the actions through a local Python library for one, and messages to and from a REST/JSON web-service in another. I fully expect that the remaining 2-3 APIs that I'll eventually have to integrate my code with will surface another Python library or a REST/JSON process that has different data-structures I'll have to contend with.

My task, with respect to all that, is to write our own API, one that can act as an adapter or wrapper around those other APIs, so that our code doesn't have to speak all those APIs' languages natively, as it were. There are (so far) 11 concrete classes that exist in parallel across those 4-5 APIs that I'll be contending with, and at a minimum, they have several common properties and methods, even if the implementations of those vary wildly from one API's object-instance to another's.

That's exactly the sort of thing that abstraction, whether in the form of an ABC or a nominal interface, is intended to help manage.

Where it started feeling sketchy was when I started thinking about how to implement the common methods. In many cases, they required arguments that were instances of subclasses of one of the ABCs. In others, arguments were expected to be the same type or set of types across all the implementations. An instance's name, for example, is going to be a str or unicode type pretty much everywhere, with the same kinds of constraints (no line-breaks or tabs, perhaps, for example).

That started me thinking: I didn't want to duplicate the type- and value-checking code for all those arguments across all of those classes — a few methods I've surfaced so far have as many as 9-10 args in their signatures. So how could I keep those in one place in order to minimize maintenance efforts in the future, while still having them accessible across all the concrete classes?

Where I eventually landed was putting concrete code into the abstract methods of the ABCs, then calling those original abstract methods from their concrete-class implementations.

By way of example, consider this simple abstract class:

class MyAbstractClass(object):
    __metaclass__ = abc.ABCMeta

    def __init__(self):
        pass

    @abc.abstractmethod
    def do_something(self, arg):
        """
Does something with arg"""
        if type(arg) not in (str, unicode):
            raise TypeError(
                '%s.do_something expects a str or '
                'unicode value for its arg, but '
                'was passed "%s" (%s)' % 
                (self.__class__.__name__, arg, 
                type(arg).__name__)
            )
The do_something method is still abstract, in that you cannot create an instance of a derived class without that class defining its own do_something method. Doing so:
class MyClass2(MyAbstractClass, object):
    pass


print 'MyAbstractClass is still abstract:'
try:
    my_object = MyClass2()
except Exception as error:
    print '%s: %s' % (error.__class__.__name__, error)
yields an error when executed:
MyAbstractClass is still abstract:
TypeError: 
    Can't instantiate abstract class MyClass2 with 
    abstract methods do_something

If, on the other hand, an implementation in a derived class calls the original do_something method from MyAbstractClass, it executes:

class MyClass(MyAbstractClass, object):

    def do_something(self, arg):
        MyAbstractClass.do_something(self, arg)
        print(
            'The argument "%s" is a %s of length %d' % 
            (arg, type(arg).__name__, len(arg))
        )


my_object = MyClass()
print 'Valid call:'
my_object.do_something('me, myself and eye')
print
print 'Raises error:'
try:
    my_object.do_something(2)
except Exception as error:
    print '%s: %s' % (error.__class__.__name__, error)
That code yields:
Valid call:
The argument "me, myself and eye" is a str of length 18

Raises error:
TypeError: 
    MyClass.do_something expects a str or unicode value 
    for its arg, but was passed "2" (int)

Problem solved, it feels like... Though it raises the question of how to keep the documentation-decoration of the original abstract method associated with the implemented concrete methods. That's something I'll have to consider.

No comments:

Post a Comment