Date: Fri, 15 Jan 2021 16:53:58 +0100
Greetings everyone,
This is my very first post so please forgive me if I'm breaking some rule(s).
Here's my humble take on a topic which has been discussed for more than 40 years, according to Bjarne Stroustrup [2]: the issue of "strong typedefs", also known as "opaque typedefs". Despite being an old topic, it is still discussed rather recently. Just to cite one example which cite many others, see [3] which uses empty scoped enumerators to achieve the wanted behaviour - albeit only for integral types.
Sitting on the shoulders of giants, I won't repeat here all the motivations and use cases thoroughly described in the proposal [1] by Walter E. Brown. This proposal has been rejected "for being far too complex for the problem solved".
Let me suggest an alternative, hopefully simpler way to provide the desired behaviour.
The goals of this proposal are:
- Provide a mean to add a clear type semantic to an API.
- Enforce this semantic at compile time so common, typo-like errors are caught as early as possible.
- No runtime overhead.
- Little compile-time overhead, if any.
- Simple to use by a developer, avoiding heavy boilerplate in most common cases, yet allowing more detailed constructs where needed.
- Intuitive to use by a developer: it should just "do the right thing" without unexpected surprise.
As shown in other articles linked from [3], a "strong typedef" could probably be provided through a library. However, having it available as a core language feature brings some benefits over a library approach:
- A library implementation involves some more or less heavy template machinery, making it not so easy to use. Moreover it would probably increase compile time, which is not desirable (I daily work on softwares that typically need hours to build).
- A library implementation implies some kind of tag to distinguish between "derived types", either a value or an empty type. The management of these tags is put on the user's responsability, making it potentially difficult to avoid clashes with locally defined derived types and ones from a third party library.
- Last but not least, making operators available (for base types having operators) can be quite cumbersome, making the simple cases ("I just want an int which I can distinguish from an int") complex to declare. For example, empty scoped enumerators don't provide operator+ "out of the box".
Please note that while the following introduces variations on existing constructs (declarations), it doesn't introduce any new keyword.
// -------
First, the "stronger type alias":
using U = new T;
With this declaration:
- Type "U" really "is a" T, not just "behaves like" as it is with usual inheritance.
- Any valid internal (binary) representation for T is a valid representation for U.
- Any valid literal to construct a T is also a valid literal to construct a U, including user-defined literals.
- Any implicit conversion to T (e.g. from constructor) is also an implicit conversion to U.
- There's an implicit conversion from U to T.
- But there's no implicit conversion from T to U.
- Any function which takes a T can also take a U, unless explicitly deleted.
- But any function which takes a U can't take a T.
- "std::underlying_type_t<U>" is "T".
- "std::is_same_v<T,U>" is false.
- "std::is_base_of_v<T,U>" is true.
Examples:
using Angle = new double;
/* ok */ Angle an_angle = 1.0;
/* ok */ double other_angle = an_angle;
/* error */ an_angle = other_angle;
/* ok */ std::cos(an_angle);
Angle std::sqrt(Angle) = delete;
/* error */ std::sqrt(an_angle);
/* ok */ std::sqrt(other_angle);
It would work with non-trivial types:
using Name = new std::string;
void Store(std::string the_name); // (1)
void Store(Name the_name); // (2)
/* ok */ Name a_name = "Yves Bailly";
std::string other_name = "Bailly Yves";
/* error */ Store("Your name here"); // ambiguous, ill-formed
/* ok */ Store(a_name); // will call (1), best match
/* ok */ Store(other_name); // will call (2), best match
/* ok */ using Names_Income = std::unordered_map<Name, double>;
// std::hash<std::string> used because of
// implicit cast from Name to std::string
// -------
Then the "strict type alias":
using U = explicit T; // or maybe "explicit new T"?
With this declaration:
- Type "U" really "is a" T, not just "behaves like" as it is with usual inheritance.
- Any valid internal (binary) representation for T is a valid representation for U.
- Any valid literal to construct a T is also a valid literal to construct a U, including user-defined literals.
- Any implicit conversion to T (e.g. from constructor) is also an implicit conversion to U.
- There's no implicit conversion between T and U.
- Functions taking a T can't take a U, unless explicitly defaulted.
- But for the sake of pragmatism, operators taking a T and producing a T can also take and produce a U.
- "std::underlying_type_t<U>" is "T".
- "std::is_same_v<T,U>" is false.
- "std::is_base_of_v<T,U>" is true.
Examples:
using Angle = explicit double;
/* ok */ Angle an_angle = 1.0;
/* error */ double other_angle = an_angle;
/* ok */ other_angle = static_cast<double>(an_angle);
/* error */ an_angle = other_angle;
/* ok */ an_angle = static_cast<Angle>(other_angle);
/* error */ std::cos(an_angle);
/* ok */ std::cos(static_cast<double>(an_angle));
Angle std::sin(Angle) = default; // maybe weird, but why not
/* ok */ auto s = std::sin(an_angle); // "s" has type "Angle"
/* ok */ an_angle *= 2.0;
With a non-trivial type:
using Name = explicit std::string;
void Store(std::string the_name); // (3)
void Store(Name the_name); // (4)
/* ok */ Name a_name = "Yves Bailly"; // ok
std::string other_name = "Bailly Yves";
/* ok */ Store("Your name here"); // will call (4)
/* ok */ Store(a_name); // will call (3)
/* ok */ Store(other_name); // will call (4)
/* error */ using Names_Income = std::unordered_map<Name, double>;
template std::hash<Name> = default;
/* ok */ using Names_Income = std::unordered_map<Name, double>; // now ok
// -------
When using templates, the "strength" of the type alias would propagate to specialisations. Examples:
using Name_Strong = new std::string;
using Names_Vector_Weak = std::vector<std::string>;
using Names_Vector_Strong = std::vector<Name_Strong>;
void print_weak(Names_Vector_Weak const& names);
Names_Vector_Strong some_strong_names{"Yves", "Bailly", "foo", "bar"};
/* ok */ print_weak(some_strong_names);
void print_strong(Names_Vector_Strong const& names);
Names_Vector_Weak some_weak_names{"Yves", "Bailly", "foo", "bar"};
/* error */ print_strong(some_weak_names);
/* ok */ print_strong(static_cast<Names_Vector_Strong const&>(some_weak_names));
using Name_Strict = explicit std::string;
using Names_Vector_Strict = std::vector<Name_Strict>;
Names_Vector_Strict some_strict_names{"fizz", "buzz"};
/* error */ print_weak(some_strict_names);
/* error */ print_strong(some_strict_names);
/* ok */ print_weak(static_cast<Names_Vector_Weak const&>(some_strict_names));
/* ok */ print_strong(static_cast<Names_Vector_Strong const&>(some_strict_names));
// -------
In short:
using U = T; // implicit casts T -> U and U -> T
using U = new T; // implicit cast U -> T
using U = explicit T; // no implicit cast
In all cases, literals and operators valid for T are also valid for U.
// -------
Would such an approach be simple and intuitive enough?
What are the potential drawbacks and pitfalls?
Finally, would it be acceptable?
Thanks for your time and for any comment :-)
// -------
References:
[1] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/p0109r0.pdf
[2] https://isocpp.org/blog/2015/11/kona-standards-meeting-trip-report (search for "P0109R0")
[3] https://accu.org/journals/overload/27/152/boger_2683/
This is my very first post so please forgive me if I'm breaking some rule(s).
Here's my humble take on a topic which has been discussed for more than 40 years, according to Bjarne Stroustrup [2]: the issue of "strong typedefs", also known as "opaque typedefs". Despite being an old topic, it is still discussed rather recently. Just to cite one example which cite many others, see [3] which uses empty scoped enumerators to achieve the wanted behaviour - albeit only for integral types.
Sitting on the shoulders of giants, I won't repeat here all the motivations and use cases thoroughly described in the proposal [1] by Walter E. Brown. This proposal has been rejected "for being far too complex for the problem solved".
Let me suggest an alternative, hopefully simpler way to provide the desired behaviour.
The goals of this proposal are:
- Provide a mean to add a clear type semantic to an API.
- Enforce this semantic at compile time so common, typo-like errors are caught as early as possible.
- No runtime overhead.
- Little compile-time overhead, if any.
- Simple to use by a developer, avoiding heavy boilerplate in most common cases, yet allowing more detailed constructs where needed.
- Intuitive to use by a developer: it should just "do the right thing" without unexpected surprise.
As shown in other articles linked from [3], a "strong typedef" could probably be provided through a library. However, having it available as a core language feature brings some benefits over a library approach:
- A library implementation involves some more or less heavy template machinery, making it not so easy to use. Moreover it would probably increase compile time, which is not desirable (I daily work on softwares that typically need hours to build).
- A library implementation implies some kind of tag to distinguish between "derived types", either a value or an empty type. The management of these tags is put on the user's responsability, making it potentially difficult to avoid clashes with locally defined derived types and ones from a third party library.
- Last but not least, making operators available (for base types having operators) can be quite cumbersome, making the simple cases ("I just want an int which I can distinguish from an int") complex to declare. For example, empty scoped enumerators don't provide operator+ "out of the box".
Please note that while the following introduces variations on existing constructs (declarations), it doesn't introduce any new keyword.
// -------
First, the "stronger type alias":
using U = new T;
With this declaration:
- Type "U" really "is a" T, not just "behaves like" as it is with usual inheritance.
- Any valid internal (binary) representation for T is a valid representation for U.
- Any valid literal to construct a T is also a valid literal to construct a U, including user-defined literals.
- Any implicit conversion to T (e.g. from constructor) is also an implicit conversion to U.
- There's an implicit conversion from U to T.
- But there's no implicit conversion from T to U.
- Any function which takes a T can also take a U, unless explicitly deleted.
- But any function which takes a U can't take a T.
- "std::underlying_type_t<U>" is "T".
- "std::is_same_v<T,U>" is false.
- "std::is_base_of_v<T,U>" is true.
Examples:
using Angle = new double;
/* ok */ Angle an_angle = 1.0;
/* ok */ double other_angle = an_angle;
/* error */ an_angle = other_angle;
/* ok */ std::cos(an_angle);
Angle std::sqrt(Angle) = delete;
/* error */ std::sqrt(an_angle);
/* ok */ std::sqrt(other_angle);
It would work with non-trivial types:
using Name = new std::string;
void Store(std::string the_name); // (1)
void Store(Name the_name); // (2)
/* ok */ Name a_name = "Yves Bailly";
std::string other_name = "Bailly Yves";
/* error */ Store("Your name here"); // ambiguous, ill-formed
/* ok */ Store(a_name); // will call (1), best match
/* ok */ Store(other_name); // will call (2), best match
/* ok */ using Names_Income = std::unordered_map<Name, double>;
// std::hash<std::string> used because of
// implicit cast from Name to std::string
// -------
Then the "strict type alias":
using U = explicit T; // or maybe "explicit new T"?
With this declaration:
- Type "U" really "is a" T, not just "behaves like" as it is with usual inheritance.
- Any valid internal (binary) representation for T is a valid representation for U.
- Any valid literal to construct a T is also a valid literal to construct a U, including user-defined literals.
- Any implicit conversion to T (e.g. from constructor) is also an implicit conversion to U.
- There's no implicit conversion between T and U.
- Functions taking a T can't take a U, unless explicitly defaulted.
- But for the sake of pragmatism, operators taking a T and producing a T can also take and produce a U.
- "std::underlying_type_t<U>" is "T".
- "std::is_same_v<T,U>" is false.
- "std::is_base_of_v<T,U>" is true.
Examples:
using Angle = explicit double;
/* ok */ Angle an_angle = 1.0;
/* error */ double other_angle = an_angle;
/* ok */ other_angle = static_cast<double>(an_angle);
/* error */ an_angle = other_angle;
/* ok */ an_angle = static_cast<Angle>(other_angle);
/* error */ std::cos(an_angle);
/* ok */ std::cos(static_cast<double>(an_angle));
Angle std::sin(Angle) = default; // maybe weird, but why not
/* ok */ auto s = std::sin(an_angle); // "s" has type "Angle"
/* ok */ an_angle *= 2.0;
With a non-trivial type:
using Name = explicit std::string;
void Store(std::string the_name); // (3)
void Store(Name the_name); // (4)
/* ok */ Name a_name = "Yves Bailly"; // ok
std::string other_name = "Bailly Yves";
/* ok */ Store("Your name here"); // will call (4)
/* ok */ Store(a_name); // will call (3)
/* ok */ Store(other_name); // will call (4)
/* error */ using Names_Income = std::unordered_map<Name, double>;
template std::hash<Name> = default;
/* ok */ using Names_Income = std::unordered_map<Name, double>; // now ok
// -------
When using templates, the "strength" of the type alias would propagate to specialisations. Examples:
using Name_Strong = new std::string;
using Names_Vector_Weak = std::vector<std::string>;
using Names_Vector_Strong = std::vector<Name_Strong>;
void print_weak(Names_Vector_Weak const& names);
Names_Vector_Strong some_strong_names{"Yves", "Bailly", "foo", "bar"};
/* ok */ print_weak(some_strong_names);
void print_strong(Names_Vector_Strong const& names);
Names_Vector_Weak some_weak_names{"Yves", "Bailly", "foo", "bar"};
/* error */ print_strong(some_weak_names);
/* ok */ print_strong(static_cast<Names_Vector_Strong const&>(some_weak_names));
using Name_Strict = explicit std::string;
using Names_Vector_Strict = std::vector<Name_Strict>;
Names_Vector_Strict some_strict_names{"fizz", "buzz"};
/* error */ print_weak(some_strict_names);
/* error */ print_strong(some_strict_names);
/* ok */ print_weak(static_cast<Names_Vector_Weak const&>(some_strict_names));
/* ok */ print_strong(static_cast<Names_Vector_Strong const&>(some_strict_names));
// -------
In short:
using U = T; // implicit casts T -> U and U -> T
using U = new T; // implicit cast U -> T
using U = explicit T; // no implicit cast
In all cases, literals and operators valid for T are also valid for U.
// -------
Would such an approach be simple and intuitive enough?
What are the potential drawbacks and pitfalls?
Finally, would it be acceptable?
Thanks for your time and for any comment :-)
// -------
References:
[1] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/p0109r0.pdf
[2] https://isocpp.org/blog/2015/11/kona-standards-meeting-trip-report (search for "P0109R0")
[3] https://accu.org/journals/overload/27/152/boger_2683/
-- (o< | Yves Bailly | -o) //\ | Software architect | //\ \_/ | http://kafka.fr.free.fr | \_/`
Received on 2021-01-15 09:54:05