Many years have passed since the introduction of “Object Oriented Design” concepts, and many programming languages have decided to adopt that approach, reject it or pick some parts of it while leaving others out. Yet, it still seems that the common description of the “Three Principles of OOD” has not been changed for decades, and it is still firm and valid in the various courses, books and even job interviews. Those three are, obviously, encapsulation, inheritance and polymorphism. Usually, they are even being listed using that order, although the logic that had previously dictated it is no longer valid for many years.
But despite that, the Object Oriented Programming have brought with it a concept that is even more important in my eyes: the constructor, a.k.a. ctor for short.
More important? Really?
Yes, absolutely; at least from the programming language’s aspect.
I will soon explain the reason for that location on the scale. But before that, a bit about how it serves us at all.
The Constructor Function
As anyone who reads this knows, a ctor function creates an object of some given type. The ctor might create a “basic” object that holds the default values for all the object’s fields. For example, it makes sense to see a code like this:
RgbColor color = new RgbColor();
that creates an object named “color” of type “RgbColor”, which its three fields—red, green and blue—are initialized to zero, setting it to the default value, black. We might think of an additional ctor for that type, for example, some code of the following form:
RgbColor color = new RgbColor(50, 100, 150);
that creates an RgbColor object whose its red component is initialized to 50, its green component is initialized to 100 and its blue component is initialized to 150. And it is also possible to define more sophisticated constructors. For examples, one that accepts a single argument:
RgbColor color = new RgbColor(128);
and creates an object whose three components are initialized to the same value, resulting in some shade of grey; or maybe with different types of parameters, say, a String type, such that the call:
RgbColor color = new RgbColor("baby-blue");
creates an object with some predefined values according to the chosen color name. There are types for which the default values are all is required; others must specifically accept some arguments; and some types are equipped with an impressive collection of constructors, allowing the creation of objects in many various ways.
So, What is it Worth For?
Many developers, therefore, treat constructors as some type of “Syntactic Sugar“: an element in the language that might save us a few keystrokes and makes our code more elegant. So, for example, this:
RgbColor(128)
is replaceable by
RgbColor(128, 128, 128)
and
RgbColor("baby-blue")
might be replaced by the actual values that represent the color #89cff0. Or we can use some helper function that returns the component values, given a string with the color’s name. In fact, even the line
RgbColor color = new RgbColor(50, 100, 150);
might alternatively be written as
RgbColor color = new RgbColor(); color.red = 50; color.green = 100; color.blue = 150;
or, if we made sure not to allow direct access to the object’s internals (encapsulation, right?), as something like this:
RgbColor color = new RgbColor(); color.setRed(50); color.setGreen(100); color.setBlue(150);
Well, as we have said: syntactic sugar.
Big deal.
A whole post, for this?
Or, maybe, this is more than yet-another syntactic sugar?
An Object vs. a Collection of Data
In fact, the ctor function is one of the most important features that distinguish a standalone entity we might call an “object” from some given collection of related data. One of the most important aspects in defining a new type is determining the rules to which all the objects of that type must obey: the type’s invariant. For a class like RgbColor, there are very simple rules, if at all. Any of the three components must have a value in the range 0–255, inclusive. In fact, if the type of each component is of a one-byte-unsigned-integer, then even if we try really hard, we won’t be able to produce an object that breaks the rules.
But what happens when we try to create objects of a class with somewhat tougher rules?
For example, assume we have a “Triangle” class, representing, well, a triangle. Here, too, any object of that type holds three fields, one for each side. Therefore, we might use a ctor similar to that we have seen, that accepts three values as arguments, say,
Triangle triangle = new Triangle(30, 40, 50);
or maybe use some other constructors as shorthand, e.g., if all sides of the triangle are the same, use something of this form:
Triangle equilateral = new Triangle(100);
Unlike colors, the sides of a Triangle will probably not be uint8 or unsigned char, but more like float, double or their relatives. On the other hand, the values of a triangle’s sides are subject to critical limitations: not any three values can compose a triangle. As the rule of Triangle Inequality states, the sum of any two sides’ lengths must be greater than the length of the remaining side in order for it to form a triangle. The values 20, 30, 100, for example, cannot be the lengths of an euclidean triangle. We might, of course, try to avoid using ctors, just the way we have demonstrated for RgbColor:
Triangle triangle = new Triangle(); triangle.setA(30); triangle.setB(40); triangle.setC(50);
This way, we will get the very same Triangle we have created earlier using a ctor, holding the values 30, 40 and 50. However, using this approach we might accidentally end up constructing a non-Triangle like this:
Triangle triangle = new Triangle(); triangle.setA(20); triangle.setB(30); triangle.setC(100);
This object does not obey the Triangle’s invariant, and is therefore invalid.
How to Deal with Illegal Values?
As we have seen, the triplet 〈 20, 30, 100 〉 cannot form a triangle. But this is not a logical matter only. Any function or method that we might call with a Triangle argument, must decide ahead how to act upon such a case. Suppose, for example, that we want to draw a triangle or to calculate its area:
x = triangle.getArea(); draw(triangle);
Those functions has preconditions that their argument must obey in order for them to run.
One option is to let each such function first verify that the values do not break the triangle inequality. If this is not the case, the function will not run at all, but instead, return an error value / throw an exception / halt the program with an assertion. Only if the preconditions are verified, will the function continue to the part of its actual purpose. Another option is to assume that the responsibility for the argument’s validity is on the caller rather than on the functions themselves. In this case, no verification will take place inside the functions.
Option 1: Preconditions Verification
The first option has lots of issues:
- It wastes lots of time on each call: if we would like to draw the Triangle one hundred times, we will have to verify its validity one hundred times.
- It requires having such verifications on many of the APIs the object exports—there might be many other methods requiring that, which will lead to lots of code duplication.
- This, in turn, might lead to a high chance of forgetting the verification here and there.
- It breaks the Single Responsibility Principle, as well as the method’s readability.
- What about methods that themselves call other methods? This might lead to several unnecessary verifications per a single call.
- Those all are true for method, but how can other functions do that at all? The function draw(), for example, might not have a Triangle as parameter but any other shape as well, in a polymorphic way. The verification in this case must also be polymorphic, then.
Option 2: No Preconditions Verification
The other way is no less of a problem: it imposes the whole responsibility on the caller of the function/method, and this, as we know, never ends well. We can certainly assume that at some point, someone will call those functions using the wrong values without any verification. What will happen then? Nobody knows. In extreme languages like C++, that might cause any Undefined Behavior, depends on the actual code of the function and the process status. In somewhat safer languages, that might hopefully end up by some exception throw, but this is not assured either. If we are not dealing with triangles area but with database manipulations, for example, the results of calling a function on a collection of values that do not constitute a valid object might be disastrous.
The Solution: Never Create a Problem
In Hebrew, there’s a phrase saying that “a wise person never gets into troubles that only a clever one can get out of”. The solution, then, is to be wise. If we write a system in which any Triangle is indeed a triangle, then any function or method that accepts Triangles as arguments can just do its work. No need in preconditions verification, no fear of unexpected results. The way to achieve that is keeping these two simple rules:
- The only way to create such an object is using a ctor (or some other function assuming that role), that never returns an invalid object
- One of these two must exist:
- either there is no way to change the object, once created
- or the only way to change the object is by specific methods/functions, that keep its type’s invariant
Should our object keep these two rules, we can safely write functions like draw() or getArea(), knowing that they will always work as expected since their Triangle argument is assured to be valid.
Wait, what’s the Big Deal? This is Just Encapsulation!
If you are experienced in OOP, you might wonder about the point of this text; after all, this is just a demonstration of the encapsulation principle. Why devoting it a whole post? Well, it turns out that many allegedly-OOD programs miss that a bit. For example, many in the C++ community, especially those “old school” guys that were introduced to OOP after many years of C programming (me included, back then!), like their classes to have some kind of init() member function. This approach separates between the constructor, whose sole purpose is to create some sort of a boilerplate object, to its actual initialization. The philosophy behind that is that “creating” an object is expected to be simple, much like creating some collection or other data structure. Initialization, on the other hand, might be a complex process with many consequences, and people that are used to procedural languages sometimes get horrified by the idea that this will happen as a “by-product” of a new variable definition, rather than in an expressed, supervised call.
Using an init() Function
Suppose that we have objects of type “Configuration”, which we generate from some file and then use their data to configure our system. According to this approach, and keeping the Single Responsibility Principle, an appropriate piece of code might look more or less like that:
Configuration conf; if (filename) conf.init(filename); else conf.init(); // Use defaults /* ... anything here ...*/ setMaxConns(conf);
In reality, initialization using a file name might not succeed. The file might be missing. We might not have the required access rights. The file might be of a wrong format. Or it might contain illegal values. This is the rationale that leads the use of a separate init() function/method. In fact, since the initialization might fail, one must add some error handling code—init() should either return an error value or throw an exception (this blog’s readers know my opinion on that matter). Therefore, the code should be something like this:
Configuration conf; try { if (filename) conf.init(filename); // Init conf's vals from the file else conf.init(); // Init conf's vals to the hard-coded defaults } catch (/* relevant exception types come here */) { /* relevant error handling comes here */ } /* ... anything here ...*/ setMaxConns(conf);
Well, we now have a code that allegedly keeps—
- The principle of encapsulation: the object’s internal structure is kept hidden, and the only way to access it is using the API exposed by this type.
- The principle of single responsibility: we have separated the “creation” of an object from its “initialization”.
Unfortunately, code that is written in that fashion, which appears in many real-world systems, is highly dangerous. The reason is simple: After finishing the try-catch blocks, there is an object named “conf” of type “Configuration”, which we use in another function—setMaxConns()—that assumes its argument is valid. However, under some circumstances, conf’s values might be invalid! Of course, we would expect the error-handling code to make sure this will never happen and the situation will never exist; but we have no control over that code whatsoever! This code is not ours. We are only responsible for the code of the Configuration class, not for code that calls it.
In fact, less careful developers might even write those two lines just like that, without understanding the stages required in order to have a fully valid, functional object:
Configuration conf; setMaxConns(conf);
The result is that one might use a “half baked” Configuration instance that does not obey the class’s invariant, thus lead to a system catastrophe.
Using a Constructor
The problem we have seen in the last paragraph will never appear if we keep the two rules we have formerly described: (1) creation of a Configuration instance will only be allowed by a ctor function that verifies its validity, and (2) any manipulation to such an existing object is only possible through API that only allows manipulations that keep the object valid. If we follow these rules, we can safely call functions like setMaxConns(conf), knowing that there is no way to pass them illegal values. A line like
Configuration conf;
will either create a valid object using the default values, or never compile, if we decided that a Configuration must be specifically defined. Likewise, a line similar to this:
Configuration conf(filename);
will never create an invalid object as well. It is our responsibility to make sure that if it creates an object, then it is valid, functional and ready to work. If the function cannot create such an object using the provided argument(s), it must either create no object at all (e.g., by throwing an exception) or create some other valid object (e.g., using the default values; of course, this will usually not be our first choice, as it only keeps the basic guarantee, but there might be cases where it is required). Now, if we provide a library containing the class Configuration, we can be sure that any call to its functions will always take place on valid objects.
And what does the Language Do for Us?
As I said at the very beginning of this post, there are several principles for OOD, and yet, I consider constructors as a concept of its own, that in some ways is even more important than the three “common” principles (although it might be considered as a sub-principle of “encapsulation”). The reason is that at this point, there is an extreme importance to the support of the programming language in this principle.
For example, let’s take a look at some procedural language like C. It is usually possible to implement the OOP principles even when programming in such a language. The full details are outside the scope of this post, but in general, it can be done that way:
- Encapsulation: We hold all the related data in a single type A, composed of a single data structure (e.g. a C’s struct), and only allow access to those data through functions on pointers-to-A.
- Inheritance: We can define a new type, B, which is a struct whose one of its field is of type A, thus having a relation that is very close to inheritance.
- Polymorphism: We can keep within the objects (of types A, B or others) pointers to functions; then, instead of calling some hard-coded function on an object, we use the function the object points to. This way, the same call might result in running a different function, according to the object’s type (in languages where functions are first-class citizens, we can hold the function itself instead of a pointer).
The paradigm we have just described keeps all three OOP principles, but (there’s always a but!) there’s a catch:
If we define some “object” of type A, it will actually be a variable of type “struct A”. The language will allow direct access to any of this object’s fields. Other words: we did not actually achieved encapsulation. If we want to implement encapsulation, we must not allow that. Well, a language like C does provide us some means to enforce that: if we only expose struct A’s declaration, but not its definition, it will be impossible to either create or access the fields of type A “objects” without using the set of API functions that actually know A’s internals. In fact, the function through which we create A objects is its constructor. Mission accomplished.
However,using this design, we will only be able to define A objects on the heap, rather than on the stack. For programmers using higher level languages this might not say much, but it comes with a few consequences, at least two of which are very critical:
- If an A object includes very little data, but we use many such objects, we will have an unimaginable waste of memory.
- Any creation or deletion of an A object might take some unknown amount of time, depending on the heap status.
This is our trade-off: reliability vs. performance.
Hard real-time environments, for example, are typically completely prohibited of using the heap. In order to keep working smoothly, such systems usually require each operation (e.g., function call) to run in no more than a known constant of time. This requirement makes the above approach inappropriate for those systems.
Languages with built-in support for constructors, on the other hand, allow us to enjoy the best of all worlds. Performance is achieved by allowing the creation of new objects on the stack which might reduce the time it takes to O(1) or even no time at all. At the same time, reliability is increased by enforcing creation through ctors only, thus allowing making sure that no accessible object ever breaks its type invariant.
What does the Future Hold for Ctors?
It is interesting to examine the approach taken by two modern languages, both claim to support both performance and reliability—Go and Rust. Both, by the way, model data and actions significantly differently than the classic OOP provided by C++ (and its cousins, like Java or C#), which its place they aim to take.
In Rust, a ctor function looks much different, syntactically, than it is in the classic OOP model. Yet, semantically, the concept of allowing the creation of a given type variables using dedicated construction function only, is widely spread into Rust’s coding culture and is supported by the language and its compiler. Indeed, the language does not provide exception throwing mechanism. Instead, its unique error-handling pattern allows a very similar behavior. If there was an error within the construction function, no “object” will be returned. Thus, if the function did create the object, while keeping the principles we have discussed in this post—it may be safely used in any function without worrying to its validity. The language provides, therefore, both the performance in variable definition and the reliability of right ctor engineering.
In Go, on the contrary, the exact opposite approach is taken, in both aspects. First, it is possible to create any variable of any type directly, without calling any function. The variable will automatically get the default “zero value” for that type. And if the zero values are illegal (like, say, when using type Triangle)—well, this is irrelevant. It will still be possible to easily create an invalid object. Moreover, Go’s error-handling approach does not guarantee that a failure in any function—either during a variable creation or while manipulating it—will prevent the invalid object from existing in the relevant scope. The responsibility, then, is almost solely on the code that uses our package, and we cannot harness the power of the language and its compiler to withhold that. (Like in C, it is possible to create more complex models to allow the concept of ctors; but without the direct support of the compiler, we will again have to deal with the performance/reliability tradeoff.)
These are few, yet very representative examples for the difference between Rust and Go—but on that, I will elaborate in some other posts.