|
STD-PROPOSALS
|
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/
--
(o< | Yves Bailly | -o)
//\ | Software architect | //\
\_/ | http://kafka.fr.free.fr | \_/`
STD-PROPOSALS list run by std-proposals-owner@lists.isocpp.org
Standard Proposals Archives on Google Groups