In-depth examples of how nkr can be put to use. If you are looking for short examples without prose, almost every entity has code samples in the reference section.
nkr::tr
Dynamically Define Concepts In-Place
Let's take a concrete example. What if we wanted to have a function that takes two std::vectors and combines them? We do not care what their value_types are, only that the second one has a value_type statically convertible to the other. Let's put together a simple algorithm and not worry about exceptions and other validations:
void Combine(auto& vector_a, auto& vector_b)
{
using vector_a_t = std::remove_reference_t<decltype(vector_a)>;
vector_a.reserve(vector_a.size() + vector_b.size());
for (auto itr = vector_b.begin(); itr != vector_b.end(); ++itr) {
vector_a.push_back(static_cast<typename vector_a_t::value_type>(*itr));
}
vector_b.clear();
}
TEST_CASE("should move elements from vector_b to vector_a")
{
std::vector<float> vector_a{ 10.0f, 20.0f };
std::vector<int> vector_b{ 30, 40 };
CHECK(vector_a.size() == 2);
CHECK(vector_b.size() == 2);
Combine(vector_a, vector_b);
CHECK(vector_a.size() == 4);
CHECK(vector_b.size() == 0);
CHECK(vector_a[0] == 10.0f);
CHECK(vector_a[1] == 20.0f);
CHECK(vector_a[2] == 30.0f);
CHECK(vector_a[3] == 40.0f);
}
It works as expected, but only when we give it valid inputs. So how can we take advantage of C++20 concepts to improve the user-experience? We ought to give them better compiler errors than this one:
TEST_CASE("will give a compile error not very friendly towards the user of our function")
{
std::vector<float> vector{ 10.0f, 20.0f };
std::forward_list<int> forward_list{ 30, 40 };
}
Actually, it might be tempting to change our algorithm so that std::forward_list will work too, but we want the algorithm that we have because it will be more performant with vectors and their reserve method. Maybe in the future we can add an overload for std::forward_list but for now we just want std::vectors. How about we try some duck-typing?:
template <typename type_p>
concept has_size =
requires(type_p instance)
{
{ instance.size() } -> std::same_as<std::size_t>;
};
void Combine_If_Has_Size(has_size auto& vector_a, has_size auto& vector_b)
{
}
TEST_CASE("should give a more user-friendly compile error")
{
std::vector<float> vector{ 10.0f, 20.0f };
std::forward_list<int> forward_list{ 30, 40 };
}
Works perfectly! Now the user cannot get a weird compiler message when they pass in a wrong type.
Right?:
TEST_CASE("will not give a user-friendly compile error")
{
std::vector<float> vector{ 10.0f, 20.0f };
std::unordered_map<std::string, int> unordered_map;
}
We surely need to react better for our user. Even if it seems like an edge case, we can never be too sure what our users will do:
class user_defined_t
{
public:
std::size_t size()
{
return 42;
}
};
TEST_CASE("will also not give a user-friendly compile error")
{
std::vector<float> vector{ 10.0f, 20.0f };
user_defined_t user_defined;
CHECK(user_defined.size() == 42);
}
Something more than duck-typing has to be done. We could add more method checks but the problem still remains, it cannot guarantee that our user will pass a std::vector. Well, there must a way to constrain to a std::vector without constraining to its value_type. Actually there's more than one way to do it:
template <typename type_p>
struct is_vector :
public std::false_type
{
};
template <typename value_type, typename allocator_type>
struct is_vector<std::vector<value_type, allocator_type>> :
public std::true_type
{
};
template <typename type_p>
concept is_vector_1 =
is_vector<type_p>::value;
static_assert(is_vector_1<std::vector<int>> == true);
static_assert(is_vector_1<std::forward_list<int>> == false);
static_assert(is_vector_1<std::unordered_map<std::string, int>> == false);
static_assert(is_vector_1<user_defined_t> == false);
void Combine_If_Is_Vector_1(is_vector_1 auto& vector_a, is_vector_1 auto& vector_b);
And here's a more concept-focused way:
template <typename type_p>
concept is_vector_2 = std::same_as<
type_p,
std::vector<typename type_p::value_type, typename type_p::allocator_type>
>;
static_assert(is_vector_2<std::vector<int>> == true);
static_assert(is_vector_2<std::forward_list<int>> == false);
static_assert(is_vector_2<std::unordered_map<std::string, int>> == false);
static_assert(is_vector_2<user_defined_t> == false);
void Combine_If_Is_Vector_2(is_vector_2 auto& vector_a, is_vector_2 auto& vector_b);
As we can see they will both function correctly, but there's a couple of problems:
static_assert(is_vector_1<const std::vector<int>> == false);
static_assert(is_vector_2<const std::vector<int>> == false);
While it's true that we want non-const std::vectors for our function, that is certainly not always going to be the case. Sometimes we actually want const and sometimes we may even want volatile types. Another issue is that it's not great having specific use-case concepts just laying about like this. In fact, it's really not great having concepts outside of our function at all. At the end of the day, we're not really interested in adding concepts to our code that will function in other contexts. We really just want our function to behave in a certain way. What we really want is to dynamically define concepts in place. Dynamic because we want to clarify qualification as well as type, and in place because we really just want it private to our particular function. And so we have nkr::tr:
{
using vector_a_t = std::remove_reference_t<decltype(vector_a)>;
vector_a.reserve(vector_a.size() + vector_b.size());
for (auto itr = vector_b.begin(); itr != vector_b.end(); ++itr) {
vector_a.push_back(static_cast<typename vector_a_t::value_type>(*itr));
}
vector_b.clear();
}
TEST_CASE("should fulfill most of our design specifications")
{
std::vector<float> vector_a;
std::vector<int> vector_b;
std::forward_list<int> forward_list;
std::unordered_map<std::string, int> unordered_map;
const std::vector<float> const_vector_a;
const std::vector<int> const_vector_b;
Combine_Non_Const_Vectors(vector_a, vector_b);
}
Used to filter a type by its qualifications, and by other types, templates, identities,...
Definition: tr_dec.h:262
nkr::tr$::tts< AND_tg, nkr::tuple::templates_t< template_p > > tt
A way to wrap a single template for use with an nkr::TR expression, to differentiate it from a type.
Definition: tr_dec.h:189
There's just one thing missing. Let's add another private and dynamically defined in-place concept within our function body, to make sure that the inner types of the std::vectors are convertible:
{
using vector_a_t = std::remove_reference_t<decltype(vector_a)>;
using vector_b_t = std::remove_reference_t<decltype(vector_b)>;
typename vector_b_t::value_type,
>, "the values of vector_b cannot be converted to the value_type of vector_a");
vector_a.reserve(vector_a.size() + vector_b.size());
for (auto itr = vector_b.begin(); itr != vector_b.end(); ++itr) {
vector_a.push_back(static_cast<typename vector_a_t::value_type>(*itr));
}
vector_b.clear();
}
TEST_CASE("should fulfill the rest of our design specifications")
{
std::vector<float> vector_a;
std::vector<int> vector_b;
std::vector<void*> vector_c;
Combine_Non_Const_Compatible_Vectors(vector_a, vector_b);
}
nkr::tr$::ts< AND_tg, nkr::tuple::types_t< type_p > > t
A way to wrap a single type for use with an nkr::TR expression, to differentiate it from a template.
Definition: tr_dec.h:176
As we have seen, by using nkr::tr we have cleanly and explicitly constrained our function as originally specified. Now we will truly only get non-const std::vectors passing through our function, all without having to statically define an exterior concept. We even use nkr::tr to ensure that their value_types are compatible. And in the future if we decide to support other types such as std::forward_list we can easily do so:
{
}
{
}
TEST_CASE("should work with non-const std::vectors or non-const std::forward_lists")
{
std::vector<float> vector_a;
std::vector<int> vector_b;
Combine_With_Overloads(vector_a, vector_b);
std::forward_list<float> forward_list_a;
std::forward_list<int> forward_list_b;
Combine_With_Overloads(forward_list_a, forward_list_b);
}
And that's just scratching the surface of what nkr::tr is capable of. With advanced usage we can constrain to multiple types and templates, their inner value_types, duck-typed generics, and even full-fledged interfaces all within a single expression.
Cleanly Negate Concepts
Let's say we have a container class with two overloads for an Add method, each of which accepts an element to add into our container. We have two overloads because we want the first one to copy lvalue references and the second one to move rvalue references. Because the lvalue reference overload has to make a copy, we allow any type that can be converted into an element_t through the method, but with the rvalue reference overload we want to keep the argument explicitly moveable so that our function can't turn into a potentially hidden and expensive operation:
enum class result_e
{
WILL_COPY,
WILL_CONVERT,
WILL_MOVE,
};
template <typename element_p>
class container_t
{
public:
using element_t = element_p;
public:
result_e Add(std::convertible_to<element_t> auto& element_to_copy_or_non_element_to_convert)
{
using argument_t = std::remove_cvref_t<decltype(element_to_copy_or_non_element_to_convert)>;
if constexpr (std::same_as<argument_t, element_t>) {
return result_e::WILL_COPY;
} else {
return result_e::WILL_CONVERT;
}
}
result_e Add(std::same_as<element_t> auto&& element_to_move)
{
return result_e::WILL_MOVE;
}
};
TEST_CASE("should select the appropriate overload depending on the type and qualification")
{
container_t<long long> container;
long long element_to_copy = 0;
long other_to_convert = 0;
long long element_to_move = 0;
CHECK(container.Add(element_to_copy) == result_e::WILL_COPY);
CHECK(container.Add(other_to_convert) == result_e::WILL_CONVERT);
CHECK(container.Add(std::move(element_to_move)) == result_e::WILL_MOVE);
}
But what happens if the user tries to move an instance that is not an element_t?
TEST_CASE("will have a confusing compile-time error when moving something other than element_t")
{
container_t<long long> container;
long other_to_move = 0;
}
Without going into the specifics of why this happens, the important take-away here is that the user is not being told what went wrong and may very well think our class is broken. After all they are trying to move an object, but for some reason it's going to the copy overload instead. Furthermore a long can certainly be converted to a long long so maybe in the user's mind it should just work.
Now there is really only one way to fix this issue as it stands, and that is to add another overload that constrains to the opposite of what we want. We can then use the delete keyword to indicate that the new overload is unavailable, or we can define the overload with a static assert that explains to the user why it's not available. In either case, we have a problem. How do we logically negate our constraint?
template <typename element_p>
class container_t
{
public:
using element_t = element_p;
public:
void Add(std::convertible_to<element_t> auto& element_to_copy_or_non_element_to_convert)
{
}
void Add(std::same_as<element_t> auto&& element_to_move)
{
}
};
As we can see, the obvious solution does not work. We actually have to use a completely different syntax to negate the concept. Doing so will solve our original problem by producing a better compile-time error, but it will also introduce yet another user-oriented issue. Our API will become more complex with the introduction of a second syntax and less intuitive for our users to read:
template <typename element_p>
class container_t
{
public:
using element_t = element_p;
public:
void Add(std::convertible_to<element_t> auto& element_to_copy_or_non_element_to_convert)
{
}
void Add(std::same_as<element_t> auto&& element_to_move)
{
}
template <typename type_p>
void Add(type_p&&) requires (!std::same_as<type_p, element_t>) = delete;
};
TEST_CASE("will have a better compile-time error")
{
container_t<long long> container;
long other_to_move = 0;
}
Instead of using the different syntax, another option is to define a custom concept outside of the class:
template <typename a, typename b>
concept not_same_as =
!std::same_as<a, b>;
template <typename element_p>
class container_t
{
public:
using element_t = element_p;
public:
void Add(std::convertible_to<element_t> auto& element_to_copy_or_non_element_to_convert)
{
}
void Add(std::same_as<element_t> auto&& element_to_move)
{
}
void Add(not_same_as<element_t> auto&&) = delete;
};
TEST_CASE("will have a better compile-time error")
{
container_t<long long> container;
long other_to_move = 0;
}
Now in this simple case it might make sense to have a custom concept defined outside of the class because it can probably be used elsewhere. However, this pattern becomes burdensome for every concept we define. Does it really make sense to create an additional concept for every custom one we make, just because it could potentially be negated? For example, what if we want to change our custom concept to additionally constrain to non-const types? After all, if our container template accepts user-defined types as elements then we cannot move them when they are const:
template <typename a, typename b>
concept same_as_non_const =
std::same_as<a, b> && !std::is_const_v<a>;
template <typename a, typename b>
concept not_same_as_non_const =
!same_as_non_const<a, b>;
template <typename element_p>
class container_t
{
public:
using element_t = element_p;
public:
void Add(std::convertible_to<element_t> auto& element_to_copy_or_non_element_to_convert)
{
}
void Add(same_as_non_const<element_t> auto&& element_to_move)
{
}
void Add(not_same_as_non_const<element_t> auto&&) = delete;
};
We really want a better solution than this. We're not interested in defining concepts that will be used in other contexts, we just want our class to behave a certain way so it's not confusing for our users. We also want our API to be easily readable, and maybe in the future we'll want to make adjustments to our method's constraints without worrying about breaking someone else's code. Enter nkr::tr:
template <typename element_p>
class container_t
{
public:
using element_t = element_p;
public:
{
}
{
}
};
TEST_CASE("should have better compile-time errors, an easier to read API, and be more robust to change")
{
container_t<long long> container;
long long element_to_copy = 0;
long other_to_convert = 0;
long long element_to_move = 0;
long other_to_move = 0;
const long long const_element_to_move = 0;
container.Add(element_to_copy);
container.Add(other_to_convert);
container.Add(std::move(element_to_move));
}
So we see how nkr::tr gives us neater, more readable, and more robust constraints which can easily be negated.
Succinctly Disambiguate Assignment Operators
Perhaps we are designing a safe boolean type that needs to be usable in both a volatile context as well as a non-volatile context. Besides supporting basic functionality such as construction of our type with bool, we also need to support all the possible combinations of volatile and non-volatile assignment to ensure that the users of our type have no issues. In other worse, non-volatile instances should be assignable by other non-volatile instances as well as by those that are volatile, and vice-versa:
class safe_bool_t
{
public:
bool value = false;
public:
safe_bool_t()
{
}
safe_bool_t(bool value)
{
}
safe_bool_t& operator =(safe_bool_t&& other) noexcept
{
return *this;
}
volatile safe_bool_t& operator =(safe_bool_t&& other) volatile noexcept
{
return *this;
}
safe_bool_t& operator =(volatile safe_bool_t&& other) noexcept
{
return *this;
}
volatile safe_bool_t& operator =(volatile safe_bool_t&& other) volatile noexcept
{
return *this;
}
};
TEST_CASE("should be able to assign value and rvalue references of itself whether volatile or not")
{
safe_bool_t safe_bool;
volatile safe_bool_t volatile_safe_bool;
safe_bool = safe_bool_t();
safe_bool = std::move(safe_bool);
safe_bool = std::move(volatile_safe_bool);
volatile_safe_bool = safe_bool_t();
volatile_safe_bool = std::move(safe_bool);
volatile_safe_bool = std::move(volatile_safe_bool);
}
However, depending on the compiler our users build their code with, they may run into an issue when trying to assign our safe_bool_t with a standard bool:
TEST_CASE("may fail to disambiguate when implicitly assigning through a constructor")
{
safe_bool_t safe_bool;
volatile safe_bool_t volatile_safe_bool;
bool unsafe_bool = false;
}
One fix is to explicitly define the assignment operators for bool, but this introduces yet two more assignment operators in our already crowded API:
class safe_bool_t
{
public:
bool value = false;
public:
safe_bool_t()
{
}
safe_bool_t(bool value)
{
}
safe_bool_t& operator =(bool value) noexcept
{
return *this;
}
volatile safe_bool_t& operator =(bool value) volatile noexcept
{
return *this;
}
safe_bool_t& operator =(safe_bool_t&& other) noexcept
{
return *this;
}
volatile safe_bool_t& operator =(safe_bool_t&& other) volatile noexcept
{
return *this;
}
safe_bool_t& operator =(volatile safe_bool_t&& other) noexcept
{
return *this;
}
volatile safe_bool_t& operator =(volatile safe_bool_t&& other) volatile noexcept
{
return *this;
}
};
TEST_CASE("should be able to disambiguate when implicitly assigning through a constructor")
{
safe_bool_t safe_bool;
volatile safe_bool_t volatile_safe_bool;
bool unsafe_bool = false;
safe_bool = bool(false);
safe_bool = unsafe_bool;
volatile_safe_bool = bool(false);
volatile_safe_bool = unsafe_bool;
}
nkr::tr offers a more succinct solution. We can achieve non-ambiguity by constraining two of our assignment operators to only accept volatile instances through use of an nkr::tr expression. It works because construction can only ever produce non-volatile values, which never satisfy our new constraint. This allows for the full range of possibilities which our cross-platform users might employ our type for, all while achieving a cleaner and less-repetitious API and implementation:
class safe_bool_t
{
public:
bool value = false;
public:
safe_bool_t()
{
}
safe_bool_t(bool value)
{
}
safe_bool_t& operator =(safe_bool_t&& other) noexcept
{
return *this;
}
volatile safe_bool_t& operator =(safe_bool_t&& other) volatile noexcept
{
return *this;
}
{
return *this;
}
{
return *this;
}
};
TEST_CASE("should work with all assignment possibilities")
{
safe_bool_t safe_bool;
volatile safe_bool_t volatile_safe_bool;
bool unsafe_bool = false;
safe_bool = safe_bool_t();
safe_bool = std::move(safe_bool);
safe_bool = std::move(volatile_safe_bool);
safe_bool = bool(false);
safe_bool = unsafe_bool;
volatile_safe_bool = safe_bool_t();
volatile_safe_bool = std::move(safe_bool);
volatile_safe_bool = std::move(volatile_safe_bool);
volatile_safe_bool = bool(false);
volatile_safe_bool = unsafe_bool;
}