Is it appropriate to use OO-style interfaces and classes when all you really need is function-like behavior?

Viewed 43

Is it appropriate to use OO-style interfaces and classes when all you really need is function-like behavior (i.e. no need to track or mutate state and the instance exists just to call its only exposed method once)?

For example, I often end up with (python) code that looks like this:

from abc import ABC, abstractmethod


class IFoo(ABC):

  @abstractmethod
  def bar(self) -> str:
    ...


class ThisFoo(IFoo):
  def __init__(self, x: str):
    self._x = x

  def bar(self) -> str:
    return f'Doing this with {self._x}'


class ThatFoo(IFoo):
  def __init__(self, x: str):
    self._x = x

  def bar(self) -> str:
    return f'Doing that with {self._x}'

The only purpose of the Foo classes is for their bar methods to be called; and the client code only needs to call it once. In fact I will often include a convenience function to wrap this behavior:

def bar(type_: str, x: str) -> str:
  dispatcher = {
    'this': ThisFoo,
    'that': ThatFoo
  }
  Foo = dispatcher[type_]
  foo = Foo(x)
  return foo.bar()

Then the client code can call:

print(bar('this', 'baz'))
print(bar('that', 'baz'))

Now, a class-based approach is of course not really necessary. Here is an equivalent purely functional implementation:

def func_bar(type_: str, x: str) -> str:
  dispatcher = {
    'this': this_bar,
    'that': that_bar
  }
  func = dispatcher[type_]
  return func(x)


def this_bar(x: str) -> str:
  return f'Doing this with {x}'


def that_bar(x: str) -> str:
  return f'Doing that with {x}'


print(func_bar('this', 'baz'))
print(func_bar('that', 'baz'))

I think one could make the case that the class-based approach is inappropriate here because, one might argue, classes should only be used when you need an object to persist and to track mutable state, which isn't the case here. In fact in my Foo implementations I could've just as easily skipped the __init__ definitions and specified x: str as a parameter in the bar method rather than have the bar method implementations reference the instance attribute self._x.

However, I believe there are a few advantages to the class-based approach.

Contract Enforcement

If I later need to add OtherFoo I will subclass the IFoo interface, and then static type-checkers like mypy will ensure that I add an appropriately defined bar method. By contrast, in the functional approach I might accidentally name my function other_abr and if I add that misspelled version to the dispatcher then static analysis tools will not catch the misspelling.

The functional approach I believe would still permit the type checker to catch an error in the signature of other_bar as long as it is added to the func_bar dispatcher. But if it doesn't get added to that function then there is no place in the code for static analysis to spot signature mismatch.

Helper method/function organization

Suppose bar needs implementations-specific helpers. In the class-based approach, the helpers would be private methods within the same class, e.g.:

from abc import ABC, abstractmethod


class IFoo(ABC):

  @abstractmethod
  def bar(self) -> str:
    ...


class ThisFoo(IFoo):
  def __init__(self, x: str):
    self._x = x

  def bar(self) -> str:
    great_text = self._helper()
    return f'Doing this with {self._x}, {great_text}'

  def _helper(self) -> str:
    return f'{self._x} is great'


class ThatFoo(IFoo):
  def __init__(self, x: str):
    self._x = x

  def bar(self) -> str:
    great_text = self._helper()
    return f'Doing that with {self._x}, {great_text}'

  def _helper(self) -> str:
    return f'{self._x} is the greatest'

The helper methods live within their respective concrete classes, which makes it clear which helper is used for which implementation. By contrast, in the functional approach the most natural thing to do would be to define the helpers as stand-alone private functions, e.g.:

def this_bar(x: str) -> str:
  great_text = _this_bar_helper(x)
  return f'Doing this with {x}'


def _this_bar_helper(x: str) -> str:
  return f'{x} is great'


def that_bar(x: str) -> str:
  great_text = _that_bar_helper(x)
  return f'Doing that with {x}'


def _that_bar_helper(x: str) -> str:
  return f'{x} is the greatest'

But then it is less clear which helper belongs to which public function (naming conventions help, but are less robust than the class methods approach), especially if you have multiple helper methods, some of which are implementation-specific and some of which might be shared by all or multiple implementations.

I suppose an alternative could be to define the implementation-specific helper functions as nested functions within their respective public functions, but then you are mixing abstraction levels within the public function and making the code more difficult to read because it is broken up by the helper function definition.

Convenience of avoiding nested parameter passing

The above code also highlights this next point. In the class approach, both the public method and the private method could access self._x, but in the functional approach, the helper methods require parameters to be passed explicitly, which means more boilerplate code around function signatures and longer function bodies because they have to pass parameters to the helper functions. This gets especially awkward when you have parameters with default arguments and find yourself typing out the same default value across multiple nested helper function signatures.

Now, some might say this is a good thing because it forces the helper functions to be explicit about which parameters they actually need. But it makes it annoying to update your code if you need to change the signature of the public function, because you might also need to update its body (where the new parameter gets passed to the helper function) and the signatures and bodies of any nested helper functions. Whereas with the class-based approach the only signature update would be to the __init__ method (or constructor in other languages).

1 Answers

Is it appropriate to use OO-style interfaces and classes when all you really need is function-like behavior?

No. If all you need is a function, then you should use a function.

If someone says their implementation is complicated enough to warrant using a class, they can still do so by simply defining a class with a __call__ method. This doesn't work the other way round: if you require an object with a method, every implementer will have to create one and write the boilerplate, no matter how short and simple their lambda could have been.

Contract Enforcement […] I might accidentally name my function other_abr and if I add that misspelled version to the dispatcher then static analysis tools will not catch the misspelling.

You're right that both the OOP and functional approaches let you specify the type that the dispatcher expects equally. I don't see what misspellings have to do with this, but static analysis also catches undeclared variables just fine.

Helper method/function organization

You would absolutely nest the function definitions here. I don't see how that "mixes abstraction levels", and I would even consider that easier to read and more straightforward than having one public method somewhere in the class - unless you know the IFoo interface by heart, how can you quickly see that there's exactly on public method? The indentation of nested functions makes it very clear what's happening.

Convenience […] in the functional approach, the helper methods require parameters to be passed explicitly, which means more boilerplate code around function signatures and longer function bodies because they have to pass parameters to the helper functions.

No they don't. If you nest the helper function definitions inside the function body, they can just access the outer function's variables by closure. Not having to create a ._x private attribute actually requires less boilerplate.

Related