Stateful chaos and ways out
Mutable data structures and imperative programming style can quickly lead to incomprehensible programs and significant slowdown in programmer's productivity. The reason is mutability of the data structures: the cognitive burden of having to remember what and when can be mutated, and what these mutations may affect. This becomes more critical as the complexity of code and data structures grows. Simply speaking, as there are more whats and whens, there are even more of what and when combinations from the phrase above: "remember what and when can be mutated". In the worst case, when any 2 program entities can potentially interact directly with each other, complexity grows exponentially in comparison to the number of entities (such as objects, functions, etc.).
There are at least 2 paths that escape from the chaos of stateful programming: introduce some discipline into mutability or give up the mutability altogether. The first approach is what OOP does, whereas the second one corresponds to the ideas of functional programming. We will concentrate on the OOP way.
OOP: disciplined statefulness
I like to think of the core OOP ideas as of means to restrict the arbitrary mutations. For instance, let's say we're modelling car movement in a game. So we have the Car class with data members (fields) like this:
speed : float gear : int rpm : float acceleration : float (from -1.0 to 1.0)
It is clear that we cannot thoughtlessly assign any value to any of the Car fields, right? First, of course, there are restrictions for each individual field. For instance, speed cannot be 1000.0 if measured in mp/h or km/h, and even more, it may not be negative if cars cannot drive backwards in your game. Similar restrictions apply to gear, rpm and acceleration.
Besides of individual restrictions, certain combinations of fields do not make sense for a single car. A fixed gear value implies that there is some well-defined relationship between rpm and speed, right? In fact, gear acts as a proportionality factor for rpm and speed. For instance, on the 1st gear the RPM of 4000 may correspond to the speed of 15 km/h, whereas the same engine speed at the 2nd gear would yield higher car speed, say 30 km/h. We can go further and assume that gear can be 0 for neutral, in which case any relationship between speed and rpm disappears.
So here is the main point of OOP: encapsulation. In other words, bringing restrictions and discipline into mutation. In our car example, there would exist certain methods, such as shift_gear() or accelerate() that know how to change the car instance's state in a meaningful way. Public car's methods are responsible for maintaining class invariants, such as the mentioned relationship between speed, gear and rpm. No one, except Car's methods, can mutate the car's state by direct assignment.
OOP: burden of maintaining invariants
OOP has escaped the total chaos of mutability, but this has its own price: now we should take care to preserve class invariants. As it was just illustrated with the Car class, all the public methods are required to leave the instance they were called on in a valid state.
Let's assume that speed can vary between 0 and 200, gear is an integer from 0 up to 5, rpm is a float 0 - 6000, acceleration is a float from -1 to 1. Let those sets of values be named, respectively, S1, S2, S3 and S4. So speed is an element of S1, gear - S2, rpm - S3, acceleration - S4.
A car object is essentially a tuple of the four values above. So a car object is an element of the set S1 x S2 x S3 x S4, that is, a Cartesian product of all the sets of the class's fields. But, as we have just concluded, not every combination of S1, S2, S3, S4 makes sense as a car object. So in fact, the set of car objects is a subset of S1 x S2 x S3 x S4.
And this is clearly the programmer's responsibility to ensure that the car instance does not jump out of this set. That may sound easier than it really is. For example, an exception may pop out in the middle of a Car's method where you didn't expect anything to be raised. This exception gets caught and the program runs on, but what about the car instance that was being mutated at the time the exception was raised? Is it still an element of the set of (correct) cars, as we just spoke? Or probably it is in an unusable state?
Because of that, it is better to have a class with few fields and many methods than vice versa. And one should think very well before adding a new field to the class. Specifically, one should ask himself/herself: how to initialize the field? what will be the value of this field at the start of each of the existing class methods? when and how can I mutate this field? how will the new field affect the existing class invariants? how to preserve the invariants now that the new field is there?
Why do people like to write classes?
So taking into consideration all the above complications with OOP and classes, I have been asking myself for quite a long time: why do people tend to create a class when they want to do something? Doesn't the word "do" imply that one needs a function, not a class? Indeed, if you can do something statelessly, you should do so. It should be obvious that the functional (stateless) approach is in most cases preferable over the imperative (stateful) one, on the assumption that the code doesn't get considerably less readable if expressed in the stateless style.
For some time, my answer to this question was that people just don't realize very well the potential complications entailed by OOP, such as those described above. Except for that, there's all this "OOP is an industrial standard" crap that drives people from thinking on their own and making right decisions.
Recently, however, I got an insight into this matter. In simple words, people often use classes because they want a bunch of functions to share some data. So they write a class, shove all the things they want to be generally accessible into self (or this, whatever it is called in your language), and then any method can access and mutate anything on self. Pretty smart, isn't it?
Class as computational context
Consider the following example in Python:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | class FancyFormatParser: def __init__(self, filename, **options): self._filename = filename self._options = options.copy() def parse(self): self._file = open(self._filename) records = [] while True: record = self._get_next_record() if record: records.append(record) else: break return records def _get_next_record(self): self._header = self._parse_header() return FancyRecord( name=self._parse_name(), profession=self._parse_prof(), age=self._parse_age(), ) def _parse_header(self): # ... # use self._file, self._options for reading # ... def _parse_name(self): # Make use of self._header, self._file, self._options def _parse_prof(self): # Make use of self._header, self._file, self._options def _parse_age(self): # Make use of self._header, self._file, self._options |
As you can see, the _file attribute is initialized in parse() and subsequently used in those other methods that need to read file data. Same with _header: it is implied that once the header is read and parsed, we store it in a globally accessible place (self), and then methods like _parse_name() can think of _header as of part of the context they are being invoked in.
Alternative: bunch of functions
The reason for such an approach is quite understandable: if we decide to implement the FancyFormatParser above in (almost) pure functional style, then we need to draw values such as _file stream or _header through arguments of all of the functions that need those values. In other words, we would get something like that:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | def parse(filename, **options): file = open(filename) records = [] while True: record = get_next_record(file, options) if record: records.append(record) else: break return records def get_next_record(file, options): header = parse_header(file, options) return FancyRecord( name=parse_name(file, header, options), profession=parse_prof(file, header, options), age=parse_age(file, header, options), ) def parse_header(file, options): # ... # use file for reading # ... def parse_name(file, header, options): # Make use of header, file def parse_prof(file, header, options): # Make use of file, header, options def parse_age(file, header, options): # Make use of file, header, options |
Did you notice how the code got slightly more verbose? More explicit formal and factual arguments. Although in this example the difference may not seem really big, but oftentimes there can be quite a number of self attributes that would have to be pulled into the methods' arguments. In this case, when you rewrite your methods to be functions with no self and getting all the needed arguments explicitly, you will discover that some of them accept 5> parameters. Which obviously doesn't feel nice.
Improvement: do everything in __init__, if possible
Let's enhance our FancyFormatParser: do the whole job directly in its __init__ method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class parse_fancy_format: def __init__(self, filename, **options): self._filename = filename self._options = options.copy() self._file = open(self._filename) records = [] while True: record = self._get_next_record() if record: records.append(record) else: break self.result = records def _get_next_record(self): # ... all the same ... |
I like this variant much more than the FancyFormatParser class we had before. Here's why. FancyFormatParser was intended to be used like this:
1 2 | parser = FancyFormatParser('big-castle.txt', length=2048, ignore=True) records = parse.parse() |
Our new class parse_fancy_format is to be used in this way:
1 | records = parse_fancy_format('big-castle.txt', length=2048, ignore=True).result |
Can you feel the difference? With FancyFormatParser the intentions were implicit. One instance should have been created and a single method should have been run on it, just one time to do the job. After that, the whole instance was not meant to be used any longer. But that wasn't immediately apparent by looking in the code.
The parse_fancy_format class is more explicit: all its methods are private, so the only thing you can do from outside is to instantiate it. Whatever needs to be done or computed is carried out directly in __init__, and then result becomes available in the attribute with a convential name, for instance, result.
The class is named as function. This is done on purpose: to emphasize that this class is going to be used almost like a function, for one-time computation. This is in contrast to the typical case when an object is deemed to live for some time and have its state mutated by methods.
However, I should agree that this convention of naming classes like functions and storing results in the object attributes is not a common approach. Moreover, the trick with attributes is quite ugly by itself. Nevertheless, this style is yet better than the initial FancyFormatParser class.
Pros and cons
The main advantage of using classes as context for one-time computations in comparison to the top-level function approach is reducing the number of formal arguments. Functions can easily share data by storing attributes on self -- that is, using self like a context. Many people find it really convenient to put things on self and have them accessible from other parts of code. This reduces the difficulty of clearly thinking out dependencies between pieces of data.
The disadvantage is that it is easy to create a standard OOP mess, when there is plenty of self attributes with implicit class invariants which is easy to break.
As a general conclusion, I think the computational context trick is quite viable and certainly has the right to exist. But one should still keep in mind the dangers of OOP described above. So
- don't shove too much onto self: if you're using some data only in the scope of a single method, put it in into a local variable, not an additional self attribute;
- if you can clearly see that certain information will be shared between a small set of methods, make it into a formal parameter, don't put on self. Explicit is better than implicit;
- if there are some objects that should be accessible almost at any point of the computation, then you should definitely make them into self attributes. For example, this can be a database connection or an output stream object.