1
\$\begingroup\$

I'm trying to generate a strong(er) type wrapper for C++. I've seen the NamedType by Jonathan Boccara, but I want a slightly different behavior.

Ideally I would be able to use something like

using StrongInt = Named<int, "description here">;

But due to limitations of C++ a const char* cannot be used directly as a template parameter

I've made the following

#include <concepts>
#include <utility>

template <typename Type, char const *Name> class Named {
public:
  using UnderlyingType = Type;

  constexpr Named() requires std::default_initializable<Type>
  = default;
  explicit constexpr Named(Type const &val) : val_{val} {}
  explicit constexpr Named(Type &&val) : val_{std::move(val)} {}
  explicit constexpr operator Type() const { return val_; }

private:
  Type val_;
};

It's quite simple like this. You can use it like

static constexpr const char strongIntName[]{"description here"};
using StrongInt = Named<int, strongIntName>;

You need to explicitly cast or initialize it to use it, enforcing strong typing. (no implicit dangers)

Doe you gals/guys/etc think I'm missing something essential here?

\$\endgroup\$
2
  • 1
    \$\begingroup\$ In C++20 you can use suitable class types as template arguments. The idea is to use a static string template class instead of a plain C string literal. In the past, I'm made static variables holding the strings, and fed those to the template argument. \$\endgroup\$
    – JDługosz
    Commented Feb 1, 2022 at 21:12
  • \$\begingroup\$ For strong types based on built-in integral types (including char), I just use a enum class. It gives the same semantics as your empty wrapper class: explicit conversion possible two/from the underlying and the strong type. \$\endgroup\$
    – JDługosz
    Commented Feb 1, 2022 at 21:18

1 Answer 1

6
\$\begingroup\$

Design review

I’ve been working on something like this for a while now, and I considered a solution similar to what you’re using before ultimately rejecting it. I also went in a different direction than Boccara.

Your current design is annoying to use, because it requires separately defining name strings for the tags. If that were the only problem, I suppose I could live with it… but the real problem is a danger that you might end up with strings with the same content nevertheless being “different” because they live at different addresses. Consider your example:

static constexpr const char strongIntName[]{"description here"};
using StrongInt = Named<int, strongIntName>;

If this is in a header—which is perfectly reasonable—but included in multiple translation units, the address of the string may be different in each translation unit.

Don’t believe me? See for yourself:

Using the pointers to the strings is a sucker’s game. It just won’t work reliably. You want to use the strings themselves. For example:

template <typename Type, std::array<char, 32> Tag> class Named {
public:
  using UnderlyingType = Type;

  constexpr Named() requires std::default_initializable<Type>
  = default;
  explicit constexpr Named(Type const &val) : val_{val} {}
  explicit constexpr Named(Type &&val) : val_{std::move(val)} {}
  explicit constexpr operator Type() const { return val_; }

private:
  Type val_;
};

static constexpr auto strongIntName = std::array<char, 32>{'f', 'o', 'o'};
using StrongInt = Named<int, strongIntName>;

And now it works.

This is still annoying, though, because I still have to define the strong type in two steps (first the name, then the actual type). It’s even uglier now that you have to spell out the string character by character (though you could use to_array()).

User defined literals to the rescue:

constexpr auto operator"" _tag(char const* s, std::size_t n) -> std::array<char, 32>
{
    auto a = std::array<char, 32>{};

    std::copy_n(s, std::min(n, std::size_t{32}), a.data());

    return a;
}

Now you can just do:

using StrongInt = Named<int, "foo"_tag>;

Now, obviously, you shouldn’t use a naked std::array like I did. You should use a proper tag type. Internally that type would just hold a std::array of chars, though. You don’t necessarily need to use a fixed size, either… but I leave dealing with that as an exercise for the reader. I’m not sure if you’ll be able to get an interface as nice as Named<int, "str">—you might be forced to swallow your pride and accept either Named<int, "str"_tag> or Named<int, tag{"str"})/Named<int, tag("str")). If so, that’s still not bad.

Now, I mentioned that I’d considered this kind of design and rejected it. That’s because I found that it is fairly rare that I just want a strongly aliased T and nothing else. For cases where I do… this is fine (though I’d drop the explicit on the conversion operation back to T; that just seems pointlessly painful). But… consider, for example, you want to use a strong int for a person’s age. With your current design:

using age_t = Named<int, "age"_tag>;

auto age = age_t{21};

All good, right?

Well, yeah, but… it’s impossible for someone to have a negative age. Wouldn’t it be nice to be able to detect and catch that at compile time? It would completely eliminate a whole class of bugs.

(As an aside, if you’re thinking, “okay then, we’ll just use unsigned int instead of int”… no, just no.)

The solution I came up with is to use a type as the tag, not just a string. Very basically:

template <typename Type, typename Desc>
class Named
{
public:
    using UnderlyingType = Type;

    explicit constexpr Named(Type const &val) : val_{Desc::validate(val)} {}
    explicit constexpr Named(Type &&val) : val_{std::move(Desc::validate(val))} {}

private:
    Type val_;
};

struct age_t_description
{
    static constexpr auto validate(int v)
    {
        if (v < 0)
            throw std::invalid_argument{"age cannot be negative"};

        return v;
    }
};

using age_t = Named<int, age_t_description>;

auto age_1 = age_t{21};
auto age_2 = age_t{-3}; // compile time error

Once you start down this road, you discover just how powerful and flexible the description type can be. I’m still exploring and experimenting, but just one example of the kind of thing I’ve explored is default constructiblity. I let the description type decide whether the final type is default constructible in several ways. For example, one possibility is:

  1. If the description type has a static member named default_construct, then: a. If default_construct is same_as Type then set the internal value to the value of default_construct in the default constructor. b. Otherwise, if default_construct is invocable and returns a type that is_convertible to Type, then call that in the default constructor. c. Otherwise, if default_construct is convertible_to Type, use that in the default constructor. d. Otherwise, if default_construct is convertible_to bool and is (compile-time) true, then use = default as the default constructor. e. Otherwise, compile-time error.
  2. Otherwise, the type is not default constructible.

So, (again, very roughly):

template <typename T, typename Tag>
class type
{
public:
    constexpr type() noexcept(is_nothrow_default_constructible<T>)
        requires same_as<Tag::default_construct, T>
        : _val{Tag::default_construct}
    {}

    constexpr type() noexcept(is_nothrow_default_constructible<T>)
        requires (not same_as<Tag::default_construct, T>
            and (invocable<Tag::default_construct> and convertible_to<invoke_result_t<Tag::default_construct>, T>))
        : _val{Tag::default_construct()}
    {}

    constexpr type() noexcept(is_nothrow_default_constructible<T>)
        requires (not same_as<Tag::default_construct, T>
            and not (invocable<Tag::default_construct> and convertible_to<invoke_result_t<Tag::default_construct>, T>)
            and (convertible_to<Tag::default_construct, bool> and bool(Tag::default_construct)))
     = default;

private:
    T _val;
};

The upshot of this is that you can do:

struct age_t_desc
{
    // default constructed age values are always 21
    static constexpr auto default_construct = 21;

    // or:
    // default constructed age values are random between 10 and 100
    static consteval auto default_construct() -> int
    {
        return compile_time_rand_between(10, 100);
    }

    // or:
    // default constructed age values are trivially constructible
    static constexpr auto default_construct = true;
};

This is not that difficult to implement, but I’ve found that figuring out the rules is extremely hard. (For example, what if T is bool. Should static constexpr auto default_construct = false; mean that type<bool, ...> is not default constructible, or is default construcible and defaults to false?) I’ve also found numerous other tricky pain points.

Anywho, your design is fine for basic strong aliasing… once you use actual string values and not pointers as tags.

Code review

template <typename Type, char const *Name> class Named {

There’s no need to pack everything on one line like this. At the very least, the class name—literally the most important thing about the class—deserves its own line, so it’s easy to spot.

Also, putting the type modifier next to the identifier, rather than the type being modified, is C style. In other words:

  • char const *Name ← this is C style
  • char const* Name ← this is C++ style

So:

template <typename Type, char const* Name>
class Named
{

That reads far better to me. (You could put the opening brace on the same line as the class name, I suppose, but why? It’s not like you’re hard up for vertical space in a computer.)

constexpr Named() requires std::default_initializable<Type>
  = default;

Might be worth adding a noexcept(std::is_nothrow_default_constructible_v<Type>) in there.

  explicit constexpr Named(Type const &val) : val_{val} {}
  explicit constexpr Named(Type &&val) : val_{std::move(val)} {}

These two lines are really the entire point of the whole class.

At the very least, you should make sure the move constructor is noexcept smart, because no-fail moving is vital for safe and performant code. While you’re at it, you might as well make the copy constructor noexcept smart as well.

But consider what would happen if you have a strong alias string type, that you want to construct with a string view:

using strong_string = Named<std::string, "whatever"_tag>;

auto sv = "foo"sv;

auto ss = strong_string{sv};    // won't work: string_view won't implicitly
                                // convert to string.

// you have to do:
auto ss = strong_string{std::string{sv}};

// this happens to work, though:
auto ss = strong_string{"foo"};
// just because std::string has an implicit constructor from char*...
// which is probably not a great idea, but there it is

That seems a bit clunky.

If I can construct a T from Args..., then I can always do:

using type = Named<T, "">;

auto t = type{T{args...}};

So wouldn’t it be nice if I could just do auto t = type{args...};? So long as sizeof(Args...) is not 1, this doesn’t seem to risk any accidental implicit conversions. And when sizeof(Args...) is 1, you can make it explicitly explicit.

That would mean this could work:

using strong_string = Named<std::string, "whatever"_tag>;

// explicit conversion and construction always works
auto ss1 = strong_string{"str"sv};  // works
auto ss2 = strong_string{"str"};    // also works (as it would anyway)
auto ss3 = strong_string{10, 'x'};  // also works (as it would with std::string)

// implicit conversion never works
auto func(strong_string);

func("str"s);   // nope
func("str"sv);  // nope
func("str");    // nope
func({"str"s}); // nope

// but explict conversion always works, of course
func(strong_string{"str"s});
func(strong_string{"str"sv});
func(strong_string{"str"});

// non-conversion construction can be explicit or not, your choice:
func(strong_string{10, 'x'});   // works
func({10, 'x'});                // *might* work, up to you

As you can see, allowing direct construction of the internal value improves usability quite a bit, may improve performance (no need for a temporary that has to be moved), and would even allow aliasing types that are non-copyable and non-movable (because they can be constructed directly in-place via forwarding).

explicit constexpr operator Type() const { return val_; }

While making conversion to the strong aliased type explicit is obviously the whole point of the thing, not sure there’s a good reason to make conversion from explicit as well. What is the worst-case scenario of implicit conversion from the strong type? There’s no chance that it might implicitly be converted to the underlying type and back, because the conversion back would be explicit.

Making the conversion from explicit just makes working with the type harder. Consider:

using mass_t = Named<double, "mass"_tag>;
using speed_t = Named<double, "speed"_tag>;

auto mass = mass_t{1.0};
auto speed = speed_t{5.0};

auto kinetic_energy = 0.5 * double{mass} * std::pow(double{speed}, 2);
// as opposed to:
auto kinetic_energy = 0.5 * mass * std::pow(speed, 2);

But it’s even worse, because using double explicitly in the casts above is brittle. If you later change the underlying type to long double, you’re silently losing precision. You have to do:

auto kinetic_energy = 0.5 * mass_t::UnderlyingType{mass} * std::pow(speed_t::UnderlyingType{speed}, 2);
// as opposed to:
auto kinetic_energy = 0.5 * mass * std::pow(speed, 2);

And you should really use decltype(mass) rather than mass_tand you might even need a couple of typenames in there, in some contexts.

And consider what you have to do to convert one strong type to another:

using temp_c_t = Named<double, "temperature in celsius"_tag>;
using temp_f_t = Named<double, "temperature in fahrenheit"_tag>;


// Celsius and Fahrenheit are the same at -40
auto temp_f = temp_f_t{-40};
auto temp_c = temp_c_t{temp_f_t::UnderlyingType{temp_f}};
// as opposed to:
auto temp_c = temp_c_t{temp_f};

I just don’t see a reason to make it difficult to convert from the strong alias to the underlying type. I don’t see any way that can introduce bugs.

One thing that’s missing from your strong alias is a way to inspect the underlying value without copying it. Suppose the underlying type is std::string, and all I want to do is print it. Currently, I have to do this:

using type = Named<std::string, "foo"_tag>;

auto val = type{"data"};

std::cout << std::string{val};  // copy-constructs a temporary string

(You could fix the above code by adding an inserter for strongly aliased types, which might be a good idea in general if the type already supports inserting int a stream. But that’s a design decision and beside the point here.)

You could add a member function to access the internal value as a reference, but that would mean the strong alias type’s public interface is different. Say you call that function value()… if the underlying type also has a value() function, then in generic code, your strong alias type will look like the underlying type, but behave differently.

So a better idea might be to add a free, friend function, somewhat like this:

template <typename Type, /*...*/ Name>
class Named
{
public:
    // ... [snip] ...

    friend constexpr auto underlying_value(Named const& n) noexcept                     -> Type const& { return n.val_; }
    friend constexpr auto underlying_value(Named&&      n) noexcept(is_nothrow_movable) -> Type        { return std::move(n.val_); }

    // ... [snip] ...
};

using type = Named<std::string, "foo"_tag>;

auto val = type{"data"};

std::cout << underlying_value(val); // no copy or move

// the r-value overload allows this:
auto&& s = underlying_value(type{"foo"});   // no copy, just a move

This is still changing the public interface, but is probably safer than adding a new member function.

\$\endgroup\$
5
  • \$\begingroup\$ Thanks for the very extensive code review: it gave me much insights. I agree your tag type is very powerful, but it's over-engineered for my situation. All I want is to have a single strong type. I don't want to implement contracts. Small thing regarding code format: this was done automatically by clang-format on save in it's "Google" settings. So according to Google, that's how you should write C++. I don't think you should claim "This is C++" "this is C". Many companies each have their own style guide. Like Bjarne/Herb say on isocpp "this is just a recommendation" \$\endgroup\$
    – JHBonarius
    Commented Jan 30, 2022 at 8:49
  • \$\begingroup\$ You're answers actually explained me why Jonathan chose the additional parameter to be typename Parameter. I didn't understand it at first, but now I see you can just declare a struct in-place. e.g. using StrongInt = NamedType<int, struct this_is_my_strong_name_type>;. I misunderstood the readme. So I'm now considering using most of their implementation. With some changes, like more concepts and noexcepts. \$\endgroup\$
    – JHBonarius
    Commented Jan 30, 2022 at 9:31
  • \$\begingroup\$ “… you can just declare a struct in-place…” That’s not a great idea. It doesn’t work the way you probably think it does, and is brittle, allowing for behaviour to silently change due to code changes far away in the code base. It’s unfortunate that Boccara is promoting this “trick”. Just define the tag struct on one line, then use it. \$\endgroup\$
    – indi
    Commented Jan 31, 2022 at 10:52
  • \$\begingroup\$ some offtop here: IMHO this strong typing shenanigans are no more than XY solution to the problem of an essential language feature like designated parameters. Contraints + designated parameters make strong typing obsolete for most of the use cases. Consider in_bounds(.latitude = 23, .longitude = 42); - it is essentally the same as .in_bounds(latitude{23}, longitude{42});, but the order doesn't matter, hence the initial reason for strongly typed parameters doesn't exist (you can't confuse them by accident). \$\endgroup\$ Commented Feb 17, 2023 at 11:16
  • \$\begingroup\$ Designated initializers hardly solve all the problems strong types solve (function parameters are not even close to the only concern), and they’re generally not great even for parameter passing because they require constructing a structure in memory, whereas individual items can often be passed in registers. See for yourself. Also, if I’m reading your comment correctly, you seem confused: the order does matter for designated initializers. (If you want arbitrary ordering with either strong types or DI, you can get it… with lots of overloading.) \$\endgroup\$
    – indi
    Commented Feb 20, 2023 at 1:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.