Making a custom type behave like std::string

110 views Asked by At

I have to define a few structs that contain a member of type std::string. I need to make them behave like std::string. In other words, they should be trivially convertible to std::string but not to each other. Instances of these structs will in turn be members of another class:

class Foo
{
    Name_t m_name;
    Description_t m_description;
// ...
};

Thus a function taking a parameter of type Name_t should not be callable with a Description_t instance even though they're both std::string under the hood.

How can this be achieved in a proper way? I tried overloading the operator std::string& for all the those types. It seems to work though. And I am not sure if I should declare them with the explicit keyword.

An example:

#include <type_traits>
#include <utility>
#include <string>
#include <cassert>


struct Description_t
{
    std::string value;

    operator std::string&( ) noexcept
    {
        return value;
    }

    operator const std::string&( ) const noexcept
    {
        return value;
    }
};

bool operator==( const Description_t& lhs, const Description_t& rhs ) noexcept
{
    // This using declaration looks too verbose to my eyes!
    using underlying_type = std::add_lvalue_reference_t<
                              std::add_const_t<
                                decltype( std::declval<decltype( lhs )>( ).value )> >;

    return static_cast<underlying_type>( lhs ) == static_cast<underlying_type>( rhs );
    // return lhs.value == rhs.value; // equivalent to the above statement
}

struct Name_t
{
    std::string value;

    operator std::string&( ) noexcept
    {
        return value;
    }

    operator const std::string&( ) const noexcept
    {
        return value;
    }
};

bool operator==( const Name_t& lhs, const Name_t& rhs ) noexcept
{
    // Again, this using declaration looks too verbose to my eyes!
    using underlying_type = std::add_lvalue_reference_t<
                              std::add_const_t<
                                decltype( std::declval<decltype( lhs )>( ).value )> >;

    return static_cast<underlying_type>( lhs ) == static_cast<underlying_type>( rhs );
    // return lhs.value == rhs.value; // equivalent to the above statement
}

int main()
{
    Description_t stock1 { "Hi!" };
    Description_t stock2 { "Hi!" };
    Name_t name1 { "Hello!" };
    Name_t name2 { "Hello!" };

    assert( name1 == name2 );
    // assert( name1 == stock1 );
}

However, in the definition for underlying_type I had to do a static_cast to const std::string& by adding ref and const to it. Otherwise, the compiler would generate two inefficient copy constructions inside the operator== to compare the values of the two Name_t arguments. Is there a more concise way of doing this (other than lhs.value == rhs.value)?

Also should the operator== be a member function in the above cases?

2

There are 2 answers

6
bitmask On

If you want a typed string that behaves like a string, CRTP with std::string inheritance would be an option:

template <typename>
struct TypeSafeString : std::string {
  using std::string::string;
};

struct Description : TypeSafeString<Description> {
  using TypeSafeString<Description>::TypeSafeString;
};

struct Name : TypeSafeString<Name> {
  using TypeSafeString<Name>::TypeSafeString;
};

A std::string will always convert to Description and to Name but no Description will convert to Name or std::string (or vice versa).

As suggested in the comments by 0xbachmann you can be even less verbose by using lambda types instead of CRTP:

using Name = TypeSafeString<decltype([]{})>;
using Description = TypeSafeString<decltype([]{})>;

But then you wont be able to add custom members, obviously.

0
digito_evo On

I believe that I crafted a solution that satisfies my needs. It's also pretty minimal (both in terms of the number of #includes and ease of readability).

Here:

#include <string>
#include <cassert>
#include <iostream>


template <typename UniqueType = decltype( []{} )>
struct [[ nodiscard ]] denominated_string_t
{
    std::string value;

    [[ nodiscard ]] constexpr auto&
    to_underlying( ) noexcept
    {
        return value;
    }

    [[ nodiscard ]] constexpr const auto&
    to_underlying( ) const noexcept
    {
        return value;
    }
};

using Name_t = denominated_string_t<>;
using Description_t = denominated_string_t<>;

constexpr bool
operator==( const Name_t& lhs, const Name_t& rhs ) noexcept
{
    return lhs.to_underlying( ) == rhs.to_underlying( );
}

constexpr bool
operator==( const Description_t& lhs, const Description_t& rhs ) noexcept
{
    return lhs.to_underlying( ) == rhs.to_underlying( );
}

int main()
{
    Description_t stock1 { "Hi!" };
    Description_t stock2 { "Hi!" };
    Name_t name1 { "Hello!" };
    Name_t name2 { "Hello!" };

    assert( name1 == name2 );

    // These don't compile which is the expected behavior
    // assert( name1 == "Hello!" );
    // assert( name1 == stock1 );
    // Name_t { stock1 };
    // std::string { name1 };

    std::getline( std::cin, name1.to_underlying( ) ); // Nice and concise!
}