Skip to main content

Registering Functions Against Object Methods in Python

My big side project right now is a music theory library in Python, called Ophis. Among many other concerns, I'm trying to make the API as natural and easy to use as possible. This often means finding ways of creating objects other than ClassName(args).

Ophis has the classes Chroma and Pitch. A chroma is a note name without an octave (the idea of C), while a pitch is a chroma with a specified octave (middle C).

The problem with this is that the conventional way of referring to a pitch would then be:

ophis.Pitch(ophis.C, 0)

You can see, Ophis has already initialized all the note names (chromae) you would need. We could do that with pitches...

C0 = Pitch(C, 0)
C1 = Pitch(C, 1)

# later, in user code...

ophis.C1

...but I think we all know the problem with that. It requires initializing several hundred pitch objects that may never be used. Most songs don't use every note. And every physical note has multiple names because of enharmonic spelling (F♯ == G♭).

So, what if the API looked like this?

ophis.C(1)

That's cool. Pretty easy to do, too.

class Chroma:

  #
  #
  #

  def __call__(self, octave):
    return Pitch(self, octave)

What if we went deeper?

Once you realize this is a good idea, the next thing you realize is.... what about chords?

ophis.Chord(ophis.C, Major)

Well, that looks pretty similar, doesn't it?

So, um... okay...

class Chroma:

    #
    #
    #

    def __call__(self, x):
        try:
          return Pitch(self, x)
        except TypeError:
          return Chord(self, x)

There are problems with this.

  • Definitions for Pitch and Chord are in modules that get loaded after Chroma. This doesn't create any errors (because the function isn't run on load), but still feels wrong.
  • It is brittle. If I change the name of Pitch or Chord, I have to go back and change it here. The tightly-wound nature of music terminology means I have long-since given up the idea of loose coupling, but I'm trying to make these types of dependencies only go up the conceptual ladder, not back down it.
  • What if I want to add more things to this method? Eventually I'm going to end up creating a series of type checks.

When I was working through this, I didn't see any way around a series of type checks, but I thought I could solve the first two problems with some creative coding.

I decided I could register functions into a dict, stored on the class. The keys for the dict would be types, and the values would be the functions to run when __call__ is called with that particular type as an argument. These functions could be registered at the point when the type that the function is supposed to return is created.

Something like...

class Chroma:

    #
    #
    #

    _callable_funcs = dict()

    def __call__(self, x, x_type=None):

        if callable(x):
            self.__class__._callable_funcs[x_type] = x
        else:
            return self.__class__._callable_funcs[type(x)](self, x)


# This code has not been tested.

I got (a version of) this to work, and I was feeling pretty darn proud of myself for thinking of this solution, and implementing it.

Then I had this feeling like this was all very familiar. Maybe I had read about this type of thing?

I quickly discovered three things:

Unfortunately, I have two problems:

  • The @singledispatch decorator only looks at the first argument of a function call. The first argument of a method call is always self. So, out of the box, this dosn't work for instance methods.
  • @singledispatch was added in v3.4, making it still a little newish. Since I'm writing a utility library for others to use, and not my own application, it seems unwise to rely on something that everyone might not have.

But, now I can do two things:

  • See if anyone has already figured out a way to apply @singledispatch to a method. (Someone has.)
  • Potentially re-implement @singledispatch myself, for backwards compatibility.

Right...

# oph_utils.py

try:
    from functools import singledispatch
except:
    # A re-implementation of @singledispatch
    # has been left as an exercise for the reader
    # because I haven't done one yet.

def method_dispatch(func):
    """
    An extension of functools.singledispatch,
    which looks at the argument after self.
    """
    dispatcher = singledispatch(func)
    def wrapper(*args, **kw):
        return dispatcher.dispatch(args[1].__class__)(*args, **kw)
    wrapper.register = dispatcher.register
    update_wrapper(wrapper, func)
    return wrapper

# chroma.py

class Chroma():

    #
    #
    #


    @oph_utils.method_dispatch
    def __call__(self, x):
        return self

# pitch.py


import chroma as ch

class Pitch:

    def __init__(self, chroma, octave=0):
          self.chroma = chroma
          self.octave = int(octave)


ch.Chroma.__call__.register(int, Pitch)


# In user code:

ophis.C(0) == ophis.Pitch(ophis.C, 0)
# True

And finally, to encourage this usage...

class Pitch:

    #
    #
    #


    __repr__(self):
        return "".join([
            self.chroma.__repr__()], "(",
            self.octave.__repr__()], ")"
            ])


# At a terminal...

>>> ophis.Pitch(ophis.C, 0)
C(0)

Feels Pythonic, yes?

Further Reading

Comments

Comments powered by Disqus