Case Classes

from macropy.case_classes import macros, case

@case
class Point(x, y): pass

p = Point(1, 2)

print(str(p)) # Point(1, 2)
print(p.x)    # 1
print(p.y)    # 2
print(Point(1, 2) == Point(1, 2)) # True
x, y = p
print(x, y)   # 1 2

Case classes are classes with extra goodies:

  • Nice __str__ and __repr__ methods autogenerated
  • An autogenerated constructor
  • Structural equality by default
  • A copy-constructor, for creating modified copies of instances
  • A __slots__ declaration, to improve memory efficiency
  • An __iter__ method, to allow destructuring

The reasoning being that although you may sometimes want complex, custom-built classes with custom features and fancy inheritance, very (very!) often you want a simple class with a constructor, pretty __str__ and __repr__ methods, and structural equality which doesn’t inherit from anything. Case classes provide you just that, with an extremely concise declaration:

@case
class Point(x, y): pass

As opposed to the equivalent class, written manually:

class Point(object):
    __slots__ = ['x', 'y']
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return "Point(" + self.x + ", " + self.y + ")"

    def __repr__(self):
        return self.__str__()

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __ne__(self, other):
        return not self.__eq__(other)

    def __iter__(self, other):
        yield self.x
        yield self.y

Whew, what a lot of boilerplate! This is clearly a pain to do, error prone to deal with, and violates DRY in an extreme way: each member of the class (x and y in this case) has to be repeated 8 times, with loads and loads of boilerplate. It is also buggy, and will fail at runtime when the above example is run, so see if you can spot the bug in it! Given how tedious writing all this code is, it is no surprise that most python classes do not come with proper __str__ or useful __eq__ functions! With case classes, there is no excuse, since all this will be generated for you.

Case classes also provide a convenient copy-constructor, which creates a shallow copy of the case class with modified fields, leaving the original unchanged:

a = Point(1, 2)
b = a.copy(x = 3)
print(a) # Point(1, 2)
print(b) # Point(3, 2)

Like any other class, a case class may contain methods in its body:

@case
class Point(x, y):
    def length(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

print(Point(3, 4).length()) # 5.0

or class variables. The only restrictions are that only the __init__, __repr__, ___str__, __eq__ methods will be set for you, and the initializer/class body and inheritance are treated specially.

Body Initializer

@case
class Point(x, y):
    self.length = (self.x**2 + self.y**2) ** 0.5

print(Point(3, 4).length) # 5

Case classes allow you to add initialization logic by simply placing the initialization statements in the class body: any statements within the class body which are not class or function definitions are taken to be part of the initializer, and so you can use e.g. the self variable to set instance members just like in a normal __init__ method.

Any additional assignments to self.XXX in the body of the class scope are detected and the XXX added to the class’ __slots__ declaration, meaning you generally don’t need to worry about __slots__ limiting what you can do with the class. As long as there is an assignment to the member somewhere in the class’ body, it will be added to slots. This means if you try to set a member of an instance via my_thing.XXX = ... somewhere else, but aren’t setting it anywhere in the class’ body, it will fail with an AttributeError. The solution to this is to simply add a self.XXX = None in the class body, which will get picked up and added to its __slots__.

The body initializer also means you cannot set class members on a case class, as it any bare assignments XXX = ... will get treated as a local variable assignment in the scope of the class’ __init__ method. This is one of several limitations.

Defaults, *args and **kwargs

Case classes also provide a syntax for default values:

@case
class Point(x | 0, y | 0):
    pass

print(str(Point(y = 5)) # Point(0, 5))

For *args:

@case
class PointArgs(x, y, [rest]):
    pass

print(PointArgs(3, 4, 5, 6, 7).rest # (5, 6, 7))

and **kwargs:

@case
class PointKwargs(x, y, {rest}):
    pass

print(PointKwargs(1, 2, a=1, b=2).rest # {'a': 1, 'b': 2})

All these behave as you would expect, and can be combined in all the normal ways. The strange syntax (rather than the normal x=0, *args or **kwargs) is due to limitations in the Python 2.7 grammar, which are removed in Python 3.3.

Inheritance

Instead of manual inheritance, inheritance for case classes is defined by _nesting_, as shown below:

@case
class List():
    def __len__(self):
        return 0

    def __iter__(self):
        return iter([])

    class Nil:
        pass

    class Cons(head, tail):
        def __len__(self):
            return 1 + len(self.tail)

        def __iter__(self):
            current = self

            while len(current) > 0:
                yield current.head
                current = current.tail

print(isinstance(List.Cons(None, None), List))    # True
print(isinstance(List.Nil(), List))               # True

my_list = List.Cons(1, List.Cons(2, List.Cons(3, List.Nil())))
empty_list = List.Nil()

print(my_list.head)              # 1
print(my_list.tail)              # List.Cons(2, List.Cons(3, List.Nil()))
print(len(my_list))              # 5
print(sum(iter(my_list)))        # 6
print(sum(iter(empty_list)))     # 0

This is an implementation of a singly linked cons list, providing both head and tail (LISP’s car and cdr) as well as the ability to get the len or iter for the list.

As the classes Nil are Cons are nested within List, both of them get transformed into case classes which inherit from it. This nesting can go arbitrarily deep.

Overriding

Except for the __init__ method, all the methods provided by case classes are inherited from macropy.case_classes.CaseClass, and can thus be overriden, with the overriden method still accessible via the normal mechanisms:

from macropy.case_classes import CaseClass

@case
class Point(x, y):
    def __str__(self):
        return "mooo " + CaseClass.__str__(self)

print(Point(1, 2)) # mooo Point(1, 2)

The __init__ method is generated, not inherited. For the common case of adding additional initialization steps after the assignment of arguments to members, you can use the body initializer described above. However, if you want a different modification (e.g. changing the number of arguments) you can achieve this by manually defining your own __init__ method:

@case
class Point(x, y):
    def __init__(self, value):
        self.x = value
        self.y = value


print(Point(1)) # mooo Point(1, 1)

You cannot access the replaced __init__ method, due to fact that it’s generated, not inherited. Nevertheless, this provides additional flexibility in the case where you really need it.

Limitations

Case classes provide a lot of functionality to the user, but come with their own set of limitations:

  • No class members: a consequence of the body initializer, you cannot assign class variables in the body of a class via the foo = ... syntax. However, @static and @class methods work fine;
  • Restricted inheritance: A case class only inherits from macropy.case_classes.CaseClass, as well as any case classes it is lexically scoped within. There is no way to express any other form of inheritance;
  • __slots__: case classes get __slots__ declarations by default. Thus you cannot assign ad-hoc members which are not defined in the class signature (the class Point(x, y) line).

Overall, case classes are similar to Python’s namedtuple, but far more flexible (methods, inheritance, etc.), and provides the programmer with a much better experience (e.g. no arguments-as-space-separated-string definition). Unlike namedtuple , they are flexible enough that they can be used to replace a large fraction of user defined classes, rather than being relegated to niche uses.

In the cases where you desperately need additional flexibility not afforded by case classes, you can always fall back on normal Python classes and do without the case class functionality.