1
\$\begingroup\$

I've tried to make a small template for the creation of distinct (incompatible) numerical types. For example both row and column indices are usually represented as the same underlying type. However it is rare that you would want to mix the two.

Code

template< typename Base, std::size_t Id>
struct Alias
{
    explicit( true  ) Alias( Base  const & inBase ): mValue( inBase ) {};
    explicit( false ) Alias( Alias const & inOther ): mValue( inOther.mValue ) {};

    ~Alias() = default;

    explicit( false ) operator Base() { return mValue; }

    Alias & operator=( Alias const & inOther ){ mValue = inOther.mValue; return *this; }

    Base mValue;
};

Usage

using Rows = Alias< std::size_t, 'rows' >;
using Cols = Alias< std::size_t, 'cols' >;

Rows row( 5 );
Cols col( 7 );

// row = col; // not allowed
auto area = row * col; // implicitly both casted to std::size, the underlying type.
// row = 5; // not allowed
row = Rows( 5 ); 

Goals

  • No overhead, I would assume that the compiler can treat code using this template the same as the underlying type
  • Allow for implicit down casting ( Alias -> Base )
  • explicit upcasting ( Base -> Alias )
  • Two distinct aliases cannot be assigned to each other without the involvement of some explicit cast

So, I am wondering is my implementation enough for this. Or am I missing subtle details about c++ that would cause this to fail?

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

This is a good idea, and it’s something similar to what I’ve been toying around with for a while (though, I’ve been focused on creating strong aliases for strings, not numbers). However, this implementation has a number of problems.

Let’s start with the obvious ones: you say it’s for numeric type aliases, but:

using alias = Alias<std::string, 'foo'>;    // works just fine!

If you really want it to be for numeric types only, you should constrain it:

template <typename T>
concept number = (
        std::integral<T>
        or std::floating_point<T>      // assuming you want *all* numbers
    )
    and (not std::same_as<T, bool>);

template <number Base, std::size_t Id>
struct Alias

But there doesn’t really seem to be a sensible reason to constrain this to only numeric type aliases.

The bigger problem is that you are using std::size_t as the discriminator. That means it’s trivial to do this:

using type_1 = Alias<int, 12345>;   // I think 12345 is a unique enough number

// elsewhere in a very large code base:
using type_2 = Alias<int, 12345>;   // no one else would use 12345!
                                    // it's the combination i use on my luggage!

You seem to think that this will be less likely if you use multi-character literals… but that actually makes things worse.

Multi-character literals are not portable. And where they do work, they may not work the way you expect.

(In case it isn’t clear what is happening there: On that platform, multi-character constants are 4 bytes wide… so they only accept 4 characters. The rest are just dropped. Only the last 4 characters are accepted. So x_vals and y_vals both get crunched down to vals. Thus the two types end up with the same ID, and the assertion fires.)

Using a std::size_t as the discriminator is not a great idea. Using multi-character literals takes it from a bad idea to a terrible one.

You have two options.

You could use types as the discriminator. For example:

template <typename Base, typename Id>
struct Alias
{
    // ...
};

struct row_tag {};
struct col_tag {};

using row_t = Alias<std::size_t, row_tag>;
using col_t = Alias<std::size_t, col_tag>;

If you go this route, there is an additional benefit: the discriminator type could also be a policy class:

template <typename Base, typename Policy>
struct Alias
{
    constexpr auto operator++() -> Alias&
        requires Policy::supports_increment
    { ++mValue; return *this; }

    // ... etc. ...
};

struct row_policy
{
    static inline constexpr bool supports_increment = true;

    // ... and so on ...
};

using row_t = Alias<std::size_t, row_policy>;

// Now this will work (well, once you also support operator<)
for (auto row = row_t{0}; row < number_of_rows; ++row)

That’s just a very simple example, but you could make it so that:

  • If the policy class has no static member named increment, the alias does not support incrementing.
  • If the policy class has a static member named increment:
    • If that static member is a bool constant:
      • If it is false, the alias does not support incrementing.
      • If it is true, the alias supports incrementing, with a default implementation.
    • If that static member is a function that takes a Base value and returns a Base value:
      • The alias supports incrementing, implemented by calling that function.

(That’s actually similar to what I’ve been working on, for strings.)

The other option, if you still want to keep things simple and use a non-type template parameter as the discriminator, is to use a string constant type. Here’s a very quick-and-dirty example:

class tag
{
public:
    static inline constexpr std::size_t max_size = 127; // should be enough for tags!

    constexpr auto operator==(tag const&) const noexcept -> bool = default;

    std::array<char, max_size + 1> chars;

    friend consteval auto operator""_tag(char const*, std::size_t) -> tag;

private:
    consteval tag(char const* p, std::size_t n)
    {
        if (n > max_size)
            throw std::invalid_argument{"tag is too long"};

        std::ranges::copy_n(p, n, chars.begin());
        chars.back() = '\0';
    }
};

consteval auto operator""_tag(char const* p, std::size_t n) -> tag
{
    return tag{p, n};
}

template <typename Base, tag Tag>
struct Alias
{
    // ...
};

using row_t = Alias<std::size_t, "rows"_tag>;
using col_t = Alias<std::size_t, "cols"_tag>;

Now you can use strings to discriminate between aliases, which should effectively reduce collisions to close to zero in practice. (And if 127 characters isn’t long enough to disambiguate all your aliases, you could make it longer.)

So now let’s look at the actual code:

    explicit( true  ) Alias( Base  const & inBase ): mValue( inBase ) {};

This is… okay. But it could be much better. It requires copying the argument. That’s probably fine if you really only mean to alias integral types. But otherwise, you should allow moving. For example:

    constexpr explicit Alias(Base const& b) noexcept(std::is_nothrow_copy_constructible_v<Base>)
        : mValue{b}
    {}
    constexpr explicit Alias(Base&& b) noexcept(std::is_nothrow_move_constructible_v<Base>)
        : mValue{std::move(b)}
    {}

Note also the added constexpr, and noexcept.

Also, as a style note, putting a space between the const and the & makes the & look like the binary AND operator. When & is being used as a reference modifier, C++ coders usually attach it to the type (same for &&; when used as rvalue reference modifier it attaches to the type, and when used as logical AND it has spaces on both sides). In other words, the C++ style is Base const& (or const Base& if you prefer west-const).

    explicit( false ) Alias( Alias const & inOther ): mValue( inOther.mValue ) {};

This is completely unnecessary. It’s basically just a bog-standard copy constructor, although writing it out like this will disable a number of optimizations. If you don’t need to explicitly specify a standard operation, don’t.

    ~Alias() = default;

This is only necessary because you have spelled out the copy constructor and copy assignment operator. Both are unnecessary (and unwise), and if removed, this line also becomes superfluous.

    explicit( false ) operator Base() { return mValue; }

I know that implicit “down-casting” is one of your design goals, but I’m not sure I’m keen on having a value that I have carefully wrapped up in a secure and safe alias suddenly and silently unwrapped.

Let’s look at your own example code to illustrate what I mean:

using Rows = Alias<std::size_t, 'rows'>;
using Cols = Alias<std::size_t, 'cols'>;

auto row = Rows{5};
auto col = Cols{7};

auto area = row * col;

So what happens if I typo:

auto area = row * row;

Suddenly all that strong aliasing is no longer worth a damn. I might as well be using naked ints.

On the other hand, if implicit unwrapping wasn’t allowed, I could write:

using Area = Alias<std::size_t, 'area'>;

constexpr auto operator*(Rows const& r, Cols const& c)
{
    return Area{r.value * c.value};
}

constexpr auto operator*(Cols const& c, Rows const& r)
{
    return r * c;
}

And now the original code would work (and give a strong type as result!), but the typo would not compile.

I also get more control, because I can choose to copy (x = y.value;) or move (x = std::move(y.value);) as I please.

Having to write .value or .get() or whatever might seem like a burden, but given the potential for silent errors and bugs with implicit unwrapping, it’s not that big a deal. And with well-written aliases that are given all the proper conversions, in my experience you only need to unwrap when you are passing values to other libraries. The rest of the time, you just use the types directly, and everything works fine, with the type system keeping you safe.

    Alias & operator=( Alias const & inOther ){ mValue = inOther.mValue; return *this; }

Explicitly writing the copy assignment operator is unnecessary, and unwise.

    Base mValue;

Making the wrapped value publicly available isn’t a terrible idea, if there are no other constraints or invariants. But in that case, you might as well give it a better name.

However, you might also consider playing it safe, and making the wrapped value private. You can provide a full set of accessors, similar to std::optional’s .value() functions.

Personally, I’d just call it value and make it public.

That’s it! Hope it helps!

\$\endgroup\$

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.