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 (theclass 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.