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.
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.
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
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_type
andconstrained_type
is like difference betweenbasic_string
andstring
. Simple one is a specialization of the more complex and powerful class.
There are two ways to react on failed constraints:
- Throw an exception
- Hold specified "null" value ("" for
std::string
, empty state akastd::nullopt
forstd::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
.
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_type
type, equal to the typeT
of wrapped value.is_nullable
boolean constant. This one works like switch between throwing and nullable mode.- You must provide
null
value ifis_nullable == true
. Wrapped value will be set to this value if constraints fail.
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.
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.
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;
};
This flag controls if operator bool
for nullable types is explicit.
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)...}
{...}
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.
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.
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
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 satisfy template requirements:
(std::predicate<decltype(Constraints), T const &> && ...)
- Constraints are evaluated in the same order as they were provided.
- Constraints should be pure. C++ doesn't allow purity checks, but most likely you want your constraints to return the same results with the same input values.
- Constraint evaluation is performed only once in constructor. You can't mutate wrapped type directly.
- This library assumes that
const
types can't be visibly changed (e. g. if you type hasmutable
fields, thanconst
methods can't change invariants). - If you really want (but you should never do this, obviously) to change wrapped type after checks are performed (and possibly destroy all the guarantees of constraint satisfaction) - make desired fields
mutable
and mutate them viaconst
methods. - If you want to change value - you should move it from wrapper and mutate as you wish. Moved-from constrained type may not satisfy constraints (if moved-from wrapped value doesn't).
This API allows user to add, clear, select and replace constraints in flexible ways.
- You can extract constraints as a value_pack using
ct::constraint_pack
. - You can replace constraints with new ones using
ct::set_constraint_pack<pack>
. - You can add constraints using
ct::add_constraint_pack<pack>
. add_constraint_pack
andset_constraint_pack
have overloads for non-type variadic packs (namedadd_constraints
andset_constraints
respectively).- You can clear all constraints using
ct::clear_constraints
. - Every "method" excluding
ct::constraint_pack
can be rewritten fromct::set_constraints<...>
form toset_constraints<ct, ...>
form.
Overloads for non-type variadic packs allows simple usage of constraint manipulation API. Advanced manipulations could be done via value_pack API.
using ct = constrained_type<T, Constraints...>;
using cs = ct::constraint_pack;
using set_ct = ct::set_constraints<...>;
using set_ct = ct::set_constraint_pack<pack>;
using add_ct = ct::add_constraints<...>;
using add_ct = ct::add_constraint_pack<pack>;
using empty_ct = ct::clear_constraints;
This API simplifies operation on value packs. Non-type variadic packs are simple, but not very handy to be processed in some kind of advanced way. This class wraps non-type packs into type and defines some utility primitives to work with.
using pack = value_pack<1, 2>;
using p_add = pack::add<3, 4>; // value_pack<1, 2, 3, 4>
using p_add = pack::add_pack<value_pack<3, 4>>; // value_pack<1, 2, 3, 4>
using p_clear = pack::clear; // value_pack<>
using pack = value_pack<1, 2, 3, 4, 5>;
using p_get = pack::get<2>; // value_pack<3>
using p_get = pack::get_many<2, 0, 2>; // value_pack<3, 1, 3>
using p_get = pack::get_range<2, 3>; // value_pack<3, 4, 5>
Combinators take values aka non-type template parameters as parameters. There are four value categories for params. From user's side there is not much of a difference - you just pass what you want. But these categories are important to understand if you want to implement you own combinators.
These are non-callable values which can be directly passed as non-type parameter.
val<42>;
These are nullary callables which can be passed as non-type parameters. You can use nullary lambdas to provide runtime-only or non-simple values. Under the hood these values are executed and combinators use returned value.
val<[]{reutrn "Hello"s;}>;
These are unary callables with T const &
compatible parameter. Under the hood checked value x is passed inside this callable and combinators use returned value.
val<[](int x){ return x % 2 == 0; }>;
val<[](auto const &x) { return x; }>;
val<&std::string::length>;
These are equivalent with Object-callable values. The only difference is that combinator values satisfy combinator
concept. This category exists only for differentiation between combinators and random object-callables. Currently, there is no real difference between these two categories. Maybe in the future combinators will have uniqie properties.
val<val<42>>;
val<lt<1, 2, 3>>;
Returns parameter A ignoring passed value x.
val<A>(x) <=> A
C++ binary operator combinator subdivides into two combinator categories - unary and n-ary. Don't be confused with arity - C++ binary operators and combinators are different.
Acts like partially right-applied binary function with (e. g. (< 2)
).
gt<c>(x) <=> x > c(x)
Acts like n-ary function of Args.
gt<c1, ..., cn>(x) <=> c1(x) > ... > cn(x)
Library defines C++ operators for operator combinators. Note, that combinatos must be constant expressions to be used in that way.
gt<10> && lt<100> <=> and_<gt<10>, lt<100>>
Relational:
eq | ==
neq | !=
lt | <
le | <=
gt | >
ge | >=
Logical:
and_ | &&
or_ | ||
C++ unary operator combinator belongs to unary combinator category. N-ary unary operators doesn't make much sense.
Acts like partially right-applied binary function with (e. g. (< 2)
).
not_<c>(x) <=> !c(x)
C++ operators defined for unary combinator operators much like for binary operators.
Logical:
not_ | !
There is only one ternary operator combinator - if_<Cond, WhenTrue, WhenFalse
combinator. It behaves like if statement.
if_<cond, true_branch, false_branch>(x) <=>
if (cond(x))
return true_branch(x);
else
return false_branch(x);