Constrained
Utility library that helps expressing invariants in types
Install / Use
/learn @JoshuaJakowlew/ConstrainedREADME
Constrained
This is a small utility library that helps expressing code invariants in types. Constrained provides constrained_type class, parametrized by the type of holding value and set of predicates, applied to it at creation.
In other words, you can associate conditions with a value at the type level. Сonditions are checked when the object is created, and if they are not met, an error occurs (an exception, or a special null value provided by the user).
Constrained types can be used, for example, to validate function parameters, to more strictly limit type values (e.g., limit the value range of int type, which stores a person's age, or limit the string length for a password), and to express various invariants in general.
Fast tutorial for the impatient
Fast example
Imagine, you have some code with invariants, that you must check unconditionally. For example, you have a function like this:
template <typename T>
T deref(T* ptr)
{
return *ptr;
}
This function has its invariant - ptr must not be nullptr. You can manually check it like this:
template <typename T>
T deref(T* ptr)
{
if (!ptr)
throw std::runtime_exception{"ptr is null"};
return *ptr;
}
That's OK, but you don't encode this invariant anywhere in function signature. User must remember, which values must be passed to the function. Also, you can forget to manually check required invariants.
With constrained_type you can write like this:
constexpr auto non_null_check = [](auto * ptr) { return ptr != nullptr; };
template <typename T>
using non_null = ct::constrained_type<T*, non_null_check>;
template <typename T>
auto deref(non_null<T> ptr)
{
return *ptr;
}
...
int x;
non_null<int> ptr{&x}; // Exception if &x is null
std::cout << deref(ptr) << '\n';
This time we encoded invariant in our type system. Now we know, which invariants we should observe as a user of this function. Also, we don't forget to write checks by hand.
Our not_null type holds non_null_check in type (using non-type template parameters). So, it doesn't affect its size and has no overhead.
Basic API reference
constrained_type is declared as the following code and parametrized by:
T- type of holding value.Constraints- predicates. These are non-type parameters, so they hold values, not types.
template <typename T, auto... Constraints>
using constrained_type = /* Complicated stuff */
You should declare predicates (you can write them directly at the type declaration).
constexpr auto age_check = [](int x) { return x > 0 && x < 150; };
Then build your constrained type and use it.
using age_t = ct::constrained_type<int, age_check>;
age_t good_age{42};
Checks are performed only once - at creation time. So, there is no way to modify holding object. You may either take a const reference, or move it from constrained_type. Value can be accessed by dereference operator, or by operator -> (e. g. for member access or method calls).
int copy = *good_age;
int move = *std::move(good_age);
age_t bad_age{-42}; // throws
When checks fails - constrained_type is in failed state. By default this leads to exception. But there is overload for std::optional<T> which doesn't throw. Then constrained_type becomes nullable. When constrained type is nullable, it doesn't throw when checks fail, and provides operator bool for validity checks. Nullability is controlled by trait objects and can be customized by user (look at Nullability Traits section).
template <>
struct ct::default_traits<int>
{
using value_type = int;
static constexpr bool is_nullable = true;
static constexpr int null = -1;
};
nullable_age_t bad_age{-42}; // doesn't throw, holding type is in "null" state
static_cast<bool>(bad_age); // false
Writing constraints is cool, but boring. Constrained provides you pre-defined combinators for easier constraint writing. Let's build somewhat synthetic, but more interesting example:
using email_t = constrained_type<std::string,
gt<&std::string::length, 10>, // Remember, we can pass different callables, not only lambdas
lt<&std::string::length, 20> // Multiple constraints applied in order
>;
constexpr auto is_even = [](int x) { return x % 2 == 0; };
constexpr auto divisible_by_5 = [](int x) { return x % 5 == 0; };
using cool_int = constrained_type<int,
gt<0>, lt<42>, // 0 < x < 42
or_<is_even, divisible_by_five> // is_even(x) || divisible_by_five(x)
>;
You can manipulate constraint sets with template magic. Let's see, how we can add constraints to given constrained type:
// From previous samples
constexpr auto age_check = [](int x) { return x > 0 && x < 150; };
using age_t = ct::constrained_type<int, age_check>;
// Add new check
constexpr auto legal_age_check = [](int x) { return x > 18; };
using legal_age_t = age_t
::add_constraints<legal_age_check>; // Create new type with added constraint
auto child = legal_age_t{10}; // Fails
auto oldman = legal_age_t{60}; // OK
auto deadman = legal_age_t{666}; // Fails
Constrained type API
Linrary provides two ways of using constrained types: a simple but somewhat limited approach and more difficult and powerful one:
basic_constrained_type<T, Trait, Config, Constraints...>class parametrized by type of wrapped value, trait type, behaviour configuration and set of constraints . It's highly customizable and powerful.constrained_type<T, Constraints...>is an alias forbase_constrained_type. Basically, it'sbasic_constrained_type<T, default_traits<T>, configuration_point{}, Constraints...>. This alias is way easier to use and is suitable for most of constraints. Basically, difference betweenbasic_constrained_typeandconstrained_typeis like difference betweenbasic_stringandstring. Simple one is a specialization of the more complex and powerful class.
Nullability traits
There are two ways to react on failed constraints:
- Throw an exception
- Hold specified "null" value ("" for
std::string, empty state akastd::nulloptforstd::optinal, etc.). Both of these modes have pros and cons. So, it's up to the user to decide which one to use. This is done via Trait template parameter ofbasic_constrained_type.
Trait
Every trait must satisfy constrained_trait concept.
Keeping it simple, here is reference implementation of default_traits<T>:
template <typename T>
struct default_traits
{
using value_type = T;
static constexpr bool is_nullable = false;
};
template <typename T>
struct default_traits<std::optional<T>>
{
using value_type = std::optional<T>;
static constexpr bool is_nullable = true;
static constexpr value_type null = std::nullopt;
};
As you can see, trait must provide:
value_typetype, equal to the typeTof wrapped value.is_nullableboolean constant. This one works like switch between throwing and nullable mode.- You must provide
nullvalue ifis_nullable == true. Wrapped value will be set to this value if constraints fail.
Custom traits
default_traits<T> are used by default, if you use simplified constrained_type<T, Constraints...> alias.
But sometimes you may need different modes for the same type. You can implement different traits and use them as you want.
Boolean conversion
If Trait::is_nullable is true then you can use operator bool. It returns true if wrapped value is equal to Trait::null value.
Explicitness of operator bool is defnend via Config template parameter.
Configuration Point
This simple struct configures behavior of basic_constrained_type. The following struct is passed as Config non-type template parameter.
Simplified constrained_type alias uses default values:
struct configuration_point
{
bool explicit_bool = true;
bool explicit_forwarding_constructor = true;
bool opaque_dereferencable = true;
bool opaque_member_accessible = true;
bool opaque_pointer_accessible = true;
};
Explicit bool
This flag controls if operator bool for nullable types is explicit.
Explicit forwarding constructor
This flag controls if the following constructor (code is simplified) is explicit.
template <typename... Args>
basic_constrained_type(Args&&... args)
requires std::is_constructible_v<T, Args...>
: _value{std::forward<Args>(args)...}
{...}
Opaque dereferencable
This switch controls if operator* returns just the wrapped value x or *x if x is dereferencable.
Imagine that we have wrapped std::optional<std::string>.
auto wrapped = wrapped_optional{"42"};
int x = **wrapped; // if opaque dereferencable flag is off.
int x = *wrapped; // if opaque dereferencable flag is on.
Opaque member accessible
Works like opaque dereferencable, but for operator->.
auto wrapped = wrapped_optional{"42"};
size_t x = wrapped->value().size(); // If opaque member accessible is off.
size_t x = wrapped->size(); // If opaque member accessible is on.
Opaque pointer accessible
This flag is not as useful as previous one. By default (with all opaque flags off) operator-> returns T* or &_value (with const when needed). In generic code this matters, if you call operator->() directly. But for pointer types address of pointer is not very useful. Probably we want value of this pointer. This flag allows this behaviour.
using wrapped_optional = constrained_type<std::string*, ...>;
auto wrapped = wrapped_optional{new std::string{"42"}};
size_t x = (*wrapped.operator->())->size(); // If opaque pointer accessible is off
size_t x = wrapped->size(); // If opaque pointer accessible is on
Constraints
The last template parameter of basic_constrained_type is auto... Constraints.
These are a set of any callables with T const &-compatible parameter returning bool.
Or, more formally, the following expression must evaluete to true to satis
