https://jmmv.dev/2021/11/cpp-ctors-vs-init.html
# Julio Merino
* About
* Essays
* Resume
* Software
* Archive
* Privacy
* Series
* Tags
[ ]
[search]
Constructors and evil initializers in C++
November 24, 2021 * About 7 minutes * Tags: c++, readability
One of the three key tenets of Object Oriented Programming (OOP) is
encapsulation: objects contain a state that, when observed from the
outside, is always internally-consistent. To illustrate this, suppose
you have a class to represent a rectangle, and that this class tracks
the rectangle's dimensions as well as its area:
class Rectangle {
int width, height, area;
public:
Rectangle(int w, int h) : width(w), height(h), area(w * h) {}
std::pair dimensions() const {
return std::make_pair(width, height);
}
int area() const {
return area;
}
void resize(int w, int h) {
width = w;
height = h;
area = w * h;
}
};
The Rectangle class above has two fields to represent its dimensions
(width and height) and a derived field to represent its area, which
we store in the object itself because it happens to be very expensive
to compute (narrator: it really is not).
If we use this class via its public interface shown above (in a
non-threaded environment), we can guarantee that area is always
up-to-date with width and height. When the object is first
constructed, all fields are in a consistent state, and if we call
resize() at a later stage, we know that all fields are also
consistent right before and right after the method call.
But note that this is not true within the class implementation: there
is a point in time in the constructor and in the resize() method
where the precomputed value of area is stale. And that's OK from an
encapsulation perspective: we only need to worry about internal
consistency at the public boundaries of the class.
Let's add error handling
Suppose we want to update our Rectangle class to ensure that width
and height are positive and not zero. Easy-peasy:
class Rectangle {
// ...
public:
Rectangle(int w, int h) {
if (w <= 0 || h <= 0) {
throw std::runtime_error("Dimensions must be positive and non-zero");
}
width = w;
height = h;
area = w * h;
}
// ...
};
All good here from an encapsulation perspective. If we try to
instantiate a Rectangle with invalid dimensions and use it, like
this:
Rectangle r(some_width, some_height);
std::cout << "The area is: " << r.area() << '\n';
... the constructor will throw an exception, the object will never have
existed, and no code after that line in the same scope will run.
There isn't any possibility in which we have an invalid r that is
accessible from the code.
Let's ban exceptions
As it turns out, there are many codebases out there that ban the use
of exceptions in C++ (Google's being a famous one). And if we don't
have exceptions, it's impossible to return errors from a constructor.
Which begs the question: how do we handle the situation above? How
can we detect errors during construction and prevent invalid objects
from ever being created? The common answer seems to be to add a
separate initialization method (say init), like this:
class Rectangle {
// ...
public:
Rectangle() : width(0), height(0), area(0) {}
bool init(int w, int h) {
if (w <= 0 || h <= 0) {
return false;
}
width = w;
height = h;
area = w * h;
return true;
}
// ...
};
And, in turn, this means that the class now has to be used like this:
Rectangle r;
if (!r.init(some_width, some_height)) {
// Invalid dimensions; handle error!
}
std::cout << "The area is: " << r.area() << '\n';
The horror. Note the abomination we have introduced here. The caller
now must go through two separate steps to create the object: one is
Rectangle r, which calls the constructor, and another is the call to
r.init(). Critically, the internal state of the object is now invalid
across these two steps, and this inconsistent state is observable
through the public interface.
Oops, we have violated the encapsulation principle. Our program can
now have instances of a Rectangle that are invalid (akin to null
values), and code can reference them just fine. This is bad. Really,
really bad. As I said a few months ago in reply to a tweet praising
RAII:
@jmmv on August 4th, 2021 * Replying to @awesomekling
Making invalid states irrepresentable is probably the best thing one
can do to improve reliability when designing a new piece of code.
It's a difficult mentality shift though!
29 likes * 5 retweets * Go to Twitter thread
Unfortunately, by separating construction from initialization like we
did above, we have gone against this principle. Yes, we had to do it
because of the constraints of our environment, but it'd be best if we
didn't have to violate a core tenet of OOP.
A possible solution
There are ways to mitigate, but not fully resolve, this problem. My
preferred way is to use a static factory method to perform the object
construction and initialization along with the required error
handling. Consider the following:
class Rectangle {
// ...
Rectangle(int w, int h) : width(w), height(h), area(w * h) {
assert(width > 0 && height > 0);
}
public:
static std::unique_ptr create(int w, int h) {
if (w <= 0 || h <= 0) {
return null;
}
return std::make_unique(w, h);
}
// ...
};
In this version, we have restored the original constructor that does
not perform validation. Objects are now always constructed in a
complete state, but it's again possible for those objects to contain
invalid state: the caller of the constructor could supply invalid
values. However, the constructor is now private, so this is sound
from an encapsulation perspective; remember that we only must
maintain consistency and validity at public interface boundaries.
All great, but... with a private constructor, we cannot create
instances of our class! To solve this, we can introduce a static
factory method that either returns a new Rectangle if the parameters
are valid, or a null pointer if the inputs are invalid. With this
approach, the public interface of Rectangle is now properly
encapsulated again: all objects returned by create() are guaranteed
to be valid, and if the parameters are invalid, no object is created
at all. (We still have to deal with null-ness though...)
Unfortunately, this code is not equivalent to the version that used
exceptions. Note that our factory method returns a heap-allocated
object and is in full control of the object's creation. This has two
problems: first, we have given up on stack-allocated objects because
just declaring an object on the stack causes its constructor to be
called; and, second, it becomes hard to deal with inheritance if some
other class wants to extend Rectangle.
Despite these issues, I'll take this version over violating
encapsulation principles anytime--unless profiling says the use of
dynamic memory is performance-critical or other coding issues prevent
it.
Takeaways
Here are the key points I'd like you to remember from this post:
1. The single best thing you can do to increase the reliability of a
program is to make invalid states impossible to represent.
2. Keep constructors "dumb": all they should be doing is assign
fields. This applies irrespectively of the use of exceptions.
3. Avoid init-like methods if at all possible. If you have actual
logic during object construction, put that code in a static
factory method (with the bonus of a better-named "constructor"),
or use dependency injection to push that logic to the caller.
4. As a corollary to the above, init-like methods that return void
are useless. Do not separate construction from initialization
just because it's a widespread practice in your codebase. Keep
all initialization in the constructor unless it's impossible to
do so.
5. If you must provide an init-like method to handle errors, do the
smallest possible amount of work within it. Any fields that can
be initialized in the constructor should be initialized there
(because this allows you to make them const, for example).
Reserve the init method for the few fields that are subject to
error checking.
6. Consider adding an is_initialized boolean to the class, and
assert that it is true in all methods (except in init, where you
would assert that it is false). This adds overhead, both to the
code and runtime, but it will help you detect the cases where you
end up using a partially-initialized object--which I guarantee
will happen.
One final thought to conclude: the reason I really enjoy writing in
Rust is because the language forces you to care about these
correctness properties (via the Result type in the context of
this post). If you don't know Rust but regularly code in C++, I'd
strongly recommend you to learn Rust as well: you'll change the way
you think about structuring your data types and algorithms, and will
spot problematic patterns in C++ with ease.
What did you think about this article? (Experimental)
(0)
(0)
Reddit logo Share on Reddit
Hacker News logo Share on Hacker News
Twitter logo Share on Twitter
Want more posts like this one? Take a moment to subscribe.
[ ] Subscribe
Follow @jmmv on Twitter
RSS feed
<< Previous post
All posts
[20181124-s]
Julio Merino
Principal Software Engineer
Currently @ Microsoft
Follow @jmmv on Twitter
RSS feed
[ ] Subscribe
Featured posts
* EndBASIC 0.8: Now, with graphics!
* EndBASIC 0.7: Hello, cloud!
* Always be quitting
* How does Google keep build times low?
* How does Google avoid clean builds?
* Unit-testing a console app (a text editor)
* Windows Subsystem for Linux: The lost potential
* Farewell, Google; hello, Microsoft!
* Configuration files and .d directories
* Bridging the web gap in EndBASIC
* More...
Archive
* 2021 (22)
+ November 2021 (2)
+ August 2021 (2)
+ July 2021 (5)
+ June 2021 (1)
+ April 2021 (2)
+ March 2021 (2)
+ February 2021 (3)
+ January 2021 (5)
* 2020 (36)
+ December 2020 (4)
+ November 2020 (5)
+ October 2020 (5)
+ September 2020 (2)
+ August 2020 (6)
+ July 2020 (2)
+ June 2020 (2)
+ May 2020 (3)
+ April 2020 (2)
+ March 2020 (2)
+ February 2020 (1)
+ January 2020 (2)
* 2019 (24)
+ December 2019 (8)
+ November 2019 (6)
+ October 2019 (1)
+ September 2019 (2)
+ March 2019 (2)
+ February 2019 (3)
+ January 2019 (2)
* 2018 (25)
+ July 2018 (3)
+ June 2018 (7)
+ May 2018 (2)
+ April 2018 (2)
+ March 2018 (8)
+ February 2018 (3)
* 2017 (6)
+ October 2017 (1)
+ August 2017 (1)
+ July 2017 (1)
+ February 2017 (3)
* 2016 (8)
+ September 2016 (1)
+ May 2016 (1)
+ April 2016 (1)
+ March 2016 (2)
+ February 2016 (1)
+ January 2016 (2)
* 2015 (17)
+ December 2015 (2)
+ October 2015 (2)
+ September 2015 (3)
+ June 2015 (2)
+ May 2015 (3)
+ April 2015 (1)
+ March 2015 (1)
+ February 2015 (3)
* 2014 (12)
+ November 2014 (2)
+ May 2014 (3)
+ March 2014 (1)
+ February 2014 (3)
+ January 2014 (3)
* 2013 (62)
+ December 2013 (7)
+ November 2013 (7)
+ October 2013 (7)
+ September 2013 (13)
+ August 2013 (9)
+ July 2013 (10)
+ June 2013 (9)
* 2012 (29)
+ October 2012 (1)
+ September 2012 (1)
+ August 2012 (3)
+ July 2012 (2)
+ June 2012 (2)
+ May 2012 (3)
+ April 2012 (1)
+ March 2012 (1)
+ February 2012 (10)
+ January 2012 (5)
* 2011 (60)
+ December 2011 (4)
+ November 2011 (4)
+ October 2011 (5)
+ September 2011 (11)
+ August 2011 (6)
+ July 2011 (4)
+ June 2011 (6)
+ May 2011 (6)
+ April 2011 (5)
+ March 2011 (2)
+ January 2011 (7)
* 2010 (26)
+ December 2010 (7)
+ September 2010 (1)
+ July 2010 (1)
+ June 2010 (2)
+ May 2010 (5)
+ April 2010 (5)
+ March 2010 (3)
+ January 2010 (2)
* 2009 (31)
+ October 2009 (1)
+ September 2009 (1)
+ August 2009 (3)
+ July 2009 (2)
+ June 2009 (4)
+ May 2009 (6)
+ April 2009 (2)
+ March 2009 (4)
+ January 2009 (8)
* 2008 (61)
+ December 2008 (1)
+ November 2008 (4)
+ October 2008 (6)
+ September 2008 (1)
+ August 2008 (6)
+ July 2008 (14)
+ June 2008 (3)
+ May 2008 (1)
+ April 2008 (3)
+ March 2008 (3)
+ February 2008 (9)
+ January 2008 (10)
* 2007 (86)
+ December 2007 (4)
+ November 2007 (7)
+ October 2007 (2)
+ September 2007 (8)
+ August 2007 (6)
+ July 2007 (15)
+ June 2007 (15)
+ May 2007 (4)
+ April 2007 (10)
+ March 2007 (8)
+ February 2007 (1)
+ January 2007 (6)
* 2006 (103)
+ December 2006 (4)
+ November 2006 (3)
+ October 2006 (7)
+ September 2006 (6)
+ August 2006 (13)
+ July 2006 (4)
+ June 2006 (13)
+ May 2006 (7)
+ April 2006 (9)
+ March 2006 (6)
+ February 2006 (13)
+ January 2006 (18)
* 2005 (129)
+ December 2005 (9)
+ November 2005 (7)
+ October 2005 (23)
+ September 2005 (10)
+ August 2005 (14)
+ July 2005 (5)
+ June 2005 (12)
+ May 2005 (6)
+ April 2005 (6)
+ March 2005 (13)
+ February 2005 (11)
+ January 2005 (13)
* 2004 (84)
+ December 2004 (9)
+ November 2004 (6)
+ October 2004 (11)
+ September 2004 (19)
+ July 2004 (29)
+ June 2004 (10)
Back to top
Copyright 2004-2021 Julio Merino
[stamp]