SkillAgentSearch skills...

Constrained

Utility library that helps expressing invariants in types

Install / Use

/learn @JoshuaJakowlew/Constrained

README

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 for base_constrained_type. Basically, it's basic_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 between basic_constrained_type and constrained_type is like difference between basic_string and string. 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 aka std::nullopt for std::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 of basic_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_type type, equal to the type T of wrapped value.
  • is_nullable boolean constant. This one works like switch between throwing and nullable mode.
  • You must provide null value if is_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

View on GitHub
GitHub Stars9
CategoryDevelopment
Updated3y ago
Forks0

Languages

C++

Security Score

60/100

Audited on Mar 25, 2023

No findings