Introduce new types. Like in Rust, differently-named structs with identical fields can
be used.
struct transaction_id {
std::uint64_t val;
}
struct log_sequence_id {
std::uint64_t val;
}
Now type safety is increased and the type mix-up is prevented by the compiler. But so
are most operations with type variables, requiring writing extra code to have the
desired functionality, compared to the first two options. Writing this extra code
will be more verbose than the same in Rust because the latter has support for traits,
which can have default implementations.
struct log_sequence_id {
...
// explicit is important, we don't want to make the incompatible types
// implicitly-covertable again inadvertently
explicit log_sequence_id(std::uint64_t v) : val{v} {}
log_sequence_id& operator += (std::size_t log_delta) {
val += log_delta;
return *this;
}
...
}
Naturally, limiting available operations is advantageous too, in both languages. For
example, it makes no sense to add two transaction IDs together.
Since this is C++, meaning that we have the template-hammer, making all the problems
look like template-nails for better or worse, we could try avoiding spelling out
structs every time:
// Written this way only to show a point. The actual implementation would be more
// complex to be able to handle move-only types and wrap large objects efficiently.
template<typename T, typename Tag>
class newtype {
public:
explicit newtype(T v) : val{v} {}
void set(T v) { val = v; }
T get() const { return val; }
private:
T val;
};
struct log_sequence_id_tag{};
using log_sequence_id = newtype<std::uint64_t, log_sequence_id_tag>;
struct transaction_id_tag{};
using transaction_id = newtype<std::uint64_t, transaction_id_tag>;
Now introducing a newtype is reduced to two lines of code. Again, C++ developers do
not usually discuss newtype but they do discuss strongly-typed using and typedefs,
which is the same thing, called differently.
In most cases we are wrapping a single value of a primitive or string type. Those
wrapped values are then operated using free functions or methods of some other
classes. Thus, in this setting, this is a great option and we are done. But suppose
we want to add some methods to the newly-introduced type instead of using free
functions. The newtype template will not allow this, not unless we introduce
inheritance:
using log_sequence_id_base = newtype<std::uint64_t, log_sequence_id_tag>;
class log_sequence_id : public log_sequence_id_base {
...
};
At which point the use of the newtype template becomes questionable and the code
simplifies by folding the value into the class:
class log_sequence_id {
public:
...
private:
std::uint64_t value;
};
Here we are back to creating a new type manually, just like before, without
templates. This seems to be different from Rust, where a single-field struct will
clearly show its newtype origins in the declaration, regardless of how much
functionality it acquired later on.
So, there you have it. Both languages are strongly typed and have means to introduce
new distinct types built on the existing ones, with Rust calling this newtype, and
developers having a choice in C++ between type aliases, which don't actually increase
type safety, to succinct templates and verbose types with some trade-offs.