Why I prefer C++ over C: templates
Published:
Mostly because of the improved type system giving me more power and control. This, in turn, allows me to program in such a way that whole classes of bugs are made impossible, because the compiler has enough information to be able to verify the source code's correctness at compile time.
Several elements of the language have to work together to produce this effect, but their number is surprisingly small:
- constructors, destructors, and RAII
- templates
- move semantics
I am human, therefore I make mistakes. For example, I could be stupid one day and write code like this:
auto addr = sockaddr_in6{};
auto port = in_port_t{2137};
addr.sin6_port = port;
The code is clearly wrong, because the sockaddr_in6::sin6_port
field is supposed to be in network byte order ie, big-endian, but there is
nothing the compiler can do to see the error.
Technically, it could special-case fields of the sockaddr_in6
structure, but what about pointers to such fields?
auto addr = sockaddr_in6{};
auto addr_port = &addr.sin6_port;
auto port = in_port_t{2137};
*addr_port = port;
The type of addr_port is in_port_t* so there is
nothing informing the compiler about mismatched endianness.
If the compiler special-cased this, the type could be something like
"in_port_t* with expected big-endian" but weaving it through
the type checker while keeping old code working would be a nightmare.
A separate static analysis tool would be better suited to this task, but a
separate tool has the distinct disadvantage of sometimes being forgotten.
Built-in guardrails are better, but in a language as old as C++ they are
difficult to add without disturbing the great mass of existing source code.
However, suppose we had a simple template called big_endian and
could write code like this:
auto addr = sockaddr_in6{};
auto port = big_endian<in_port_t>{2137};
addr.sin6_port = port;
The compiler would issue an error here, because
big_endian<in_port_t> is clearly not the same type as
in_port_t and it is not clear what should happen here.
Let's assume the big_endian wrapper would have a few member
functions:
auto to_native() const -> Tfor spitting out host-endian numbers (but let's use "native" instead of "host" to mimic the names ofstd::endian's values)auto to_big() const -> Tfor spitting out implicitly big-endian numbersauto to_little() const -> Tfor spitting out implicitly little-endian numbers
Then, the code would look like this:
auto addr = sockaddr_in6{};
auto port = big_endian<in_port_t>{2137};
addr.sin6_port = port.to_big();
A general solution
If you see this type and immediately ask yourself "what about explicitly little-endian numbers?" then wonder no more. A template handling both types can be written as well:
/*
* Copyright (C) 2026 Marek Marecki
*
* This source code is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This source code is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this source code. If not, see <http://www.gnu.org/licenses/>.
*/
#if !defined(MAKADEADE_EXPLICIT_ENDIAN_H)
#define MAKADEADE_EXPLICIT_ENDIAN_H
#include <bit>
#include <concepts>
#include <type_traits>
namespace makadeade {
template<typename T, std::endian Endian = std::endian::native>
requires std::integral<T>
struct explicit_endian
{
T value;
explicit_endian() = delete;
constexpr explicit_endian(T const v, std::endian const e) noexcept
: value{Endian == e ? v : std::byteswap(v)}
{}
template<typename O, std::endian Other_endian>
requires std::integral<O>
constexpr explicit_endian(explicit_endian<O, Other_endian> const o) noexcept
: value{Endian == Other_endian ? o.value : std::byteswap(o.value)}
{}
template<typename O, std::endian Other_endian>
requires std::integral<O>
constexpr explicit_endian(explicit_endian<O, Other_endian>&& o) noexcept
: value{Endian == Other_endian ? std::move(o.value) : std::byteswap(std::move(o.value))}
{}
template<typename O, std::endian Other_endian>
requires std::integral<O>
auto operator=(explicit_endian<O, Other_endian> const o) noexcept
-> explicit_endian<T, Endian>&
{
value = Endian == Other_endian ? o.value : std::byteswap(o.value);
return *this;
}
template<typename O, std::endian Other_endian>
requires std::integral<O>
auto operator=(explicit_endian<O, Other_endian>&& o) noexcept
-> explicit_endian<T, Endian>&
{
value = Endian == Other_endian ? std::move(o.value) : std::byteswap(std::move(o.value));
return *this;
}
constexpr auto is_native() const noexcept -> bool
{
return Endian == std::endian::native;
}
constexpr auto to_big() const noexcept -> T
{
return Endian == std::endian::big ? value : std::byteswap(value);
}
constexpr auto to_little() const noexcept -> T
{
return Endian == std::endian::little ? value : std::byteswap(value);
}
constexpr auto to_native() const noexcept -> T
{
return is_native() ? value : std::byteswap(value);
}
};
}
#endif
Equipped with the above template, you can create beautifully type-safe values like this:
auto const port = explicit_endian<in_port_t, std::endian::big>{
2137, std::endian::native
};
You explicitly specify what value you want (in_port_t) and
in what endianness (std::endian::big) in the template
parameters;
and then you give the constructor a raw integer (2137) and
its endianness (std::endian::native).
Is this too much information? I do not think so. I think this is exactly as much information as is needed to ensure the program does The Right Thing, and, even better, this is information that the compiler can use. Consider a C snippet:
uint16_t port = 2137;
It is much shorter, but also conveys much less information to the reader. There may be some comments above or below the line eg, "this value is in big endian", but who can promise the comment is up to date, and who can prove the constraints it sets are actually enforced? These questions do not need to be asked in the C++ version: the comments are unnecessary because the code encodes the constraints, and the compiler enforces them automatically.
To print or assign such values, use either to_native(),
to_big(), or to_little() functions, as
appropriate:
std::println("port = {}", port.to_native());
addr.sin6_port = port.to_big();
This templates makes worries about correct tracking of endianness a thing of the past. You only need to state what values you create, and what values you hand out at your source code's boundaries eg, when calling a function provided by the operating system or a foreign library. Everything that happens inside those boundaries will be correct, and the compiler will prove it.
The human condition
To restate what I have said earlier: I am human, therefore I make mistakes. This terrible lack of infallibility makes me consider the type system of a language, and the compiler that enforces it, some of my most important allies in the struggle for correctness.
Of course, what I described in this post is a silly little example, but such silly little examples are precisely the bricks we use to build the cathedrals of our software. In my personal opinion, the quality of the bricks matters as much as the architecture.
There is a saying in Polish: "z gówna bicza nie ukręcisz". Literally, it translates to "you will not make a whip out of shit" or "you cannot make a whip out of shit". Taken metaphorically, it can be understood as saying that you cannot make something truly useful out of bad quality, or inappropriate, materials.
Or, to give a more family-friendly example, take the fable about The Three Little Pigs which teaches the same lesson: you build your house our of bricks, not out of mud or sticks.
I wholeheartedly agree with both the saying and the fable, and strive to apply their lesson when writing my software.
Source of the cover image: Wikipedia
Previous: Installing Hy with uv