Skip to content

Vociferix/sumty

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

77 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Sumty 🍵

API Docs | Coverage

sumty is a header-only C++20 library that provides "better" sum types. The C++ standard library provides std::variant, std::optional, and std::exepcted (as of C++23), but these have some limitiations that sumty aims to remove.

Contents

  1. The Problem
  2. The Solution
  3. Overview
  4. Project Integration

The Problem

The C++ language does not provide proper sum types. It does provide unions, but these are unsafe and often difficult to use correctly, especially within constexpr contexts. The standard library's answer to this deficiency in the language are the generic types std::variant, std::optional, and std::expected. std::variant is a general purpose, type safe, discriminated union. std::optional is a representation of an object that either contains some value or contains nothing (null or none). std::exepcted, added in C++23, represents an object that either contains an "expected" value or an "unexpected" value. Although it can be used for other purposes, an "expected" value is generally a non-error, normal execution value, and an "unexpected" value is generally an error of some kind, providing an alternative to exceptions.

These types are incredibly useful, but they have a few limitations and miss some opportunites for optimization. In particular, none of these types allow references (lvalue or rvalue) to be one of the alternative types. std::variant and std::optional also disallow void from being an alternative type, but std::expected does allow void for the "expected" type. For application authors, these limitations are usually not an issue since these types either don't come up or have alternate ways to be represented in non-generic code. For example, T* can be used instead of std::optional<T&>, and bool can be used instead of std::optional<void>. But for library authors, where APIs are much more likely to be generic, these limitations do become a problem.

If a generic function expects or returns an optional value of any type chosen by the user, the library either must disallow the same types dissallowed by std::optional, or provide and document specializations specifically for references and void which will have a slightly different API to suit those types. std::optional and std::expected, in particular are intended for use on API boundaries, so this comes up quite frequently. For std::variant, this problem comes up more often in other contexts, such as being a member of a generic data structure.

Here is a motivating example using std::optional:

// Calls `fn` if `condition` is `true`, and returns the result. Otherwise, returns null.
template <typename F>
std::optional<std::invoke_result_t<F>> call_if(bool condition, F fn) {
    std::optional<std::invoke_result_t<F>> ret;
    if (condition) {
        ret.emplace(std::invoke(fn));
    }
    return ret;
}

Without writing additional overloads of call_if, the provided function must return a value (cannot return void). It also cannot return a reference. A minor adjustment could allow a returned reference to be converted to an rvalue by copying the referenced value, but there is a strong chance that is not the desired behavior and would be easy to misuse.

The Solution

sumty solves the problem described above by providing sum types that do allow void and references as alternative types. The previous motivating example can be rewritten as shown below.

// Calls `fn` if `condition` is `true`, and returns the result. Otherwise, returns null.
template <typename F>
sumty::option<std::invoke_result_t<F>> call_if(bool condition, F fn) {
    sumty::option<std::invoke_result_t<F>> ret;
    if (condition) {
        // `sumty::invoke` cleanly handles the case where `fn` returns `void`
        ret.emplace(sumty::invoke(fn));
    }
    return ret;
}

This new version of call_if properly handles when fn returns an lvalue reference, an rvalue reference, or void. In addition, the use of sumty::invoke makes it so call_if doesn't even need to specially handle void internally.

sumty also takes these sum types a step further with size optimizations. lvalue references have the particular quality that they are non-null, despite having the same representation as a pointer. sumty::option<T&> can use nullptr to represent "null" or "none". More generally, any sum type with two alternatives where one alternative is an lvalue reference and the other is either void or an empty type, can use nullptr to represent the non-lvalue reference alternative. Thus, any such sum type is the size of a pointer.

sumty also optimizes the size of sum types comprised entirely of void or empty types. For example, sumty::option<void> and sumty::option<std::monostate> are the same size as a bool. More generally, a sum type of all void or empty type alternatives is the size of the smallest integer required to count the alternatives. sumty::variant<void, std::monostate, void> is the size of a uint8_t, and sumty::variant<void> is an empty type.

Below is a table of the sum types sumty provides and the standard library type that it tries to improve upon, if applicable.

sumty STL
sumty::variant<T...> std::variant<T...>
sumty::option<T> std::optional<T>
sumty::result<T, E> std::expected<T, E>
sumty::error_set<T...> N/A

Overview

  1. sumty::variant
  2. sumty::option
  3. sumty::result
  4. sumty::error_set
  5. sumty::never

sumty::variant

API Docs

variant is a general purpose discriminated union. It has mostly the same purpose and API as std::variant, but there are some important differences aside from expanded alternative types and size optimizations. Primarily, sumty::variant does not allow type-based alternative selection unless such a selection can be unambiguous.

// compiles and initializes the first alternative
std::variant<int, int> v1 = 42;

// does not compile since `42` matches more than one alternative
sumty::variant<int, int> v2 = 42;

This difference is by design to improve readability. In the case of std::variant, a less experienced developer may not be sure what should happen in our above example, even if it is well defined behavior. With sumty::variant, index based alternative selection always works and is always clear.

Unlike std::variant, sumty::variant also implements the index operator. However, this operator uses the special compile time indexing types, sumty::index_t and sumty::type_t.

sumty::variant<int, std::string, void> v{"hello"};

auto& s_by_index = v[sumty::index<1>];

auto& s_by_type = v[sumty::type<std::string>];

For the most part, the index operator is an alternative syntax for get (see docs and std::get for std::variant). But get throws an exception if the requested alternative is incorrect, while the index operator is unchecked, resulting in undefined behavior if the variant does not contain the requested alternative. std::variant provides no API for accessing an alternative unchecked.

The last major difference of sumty::variant from std::variant is the addition of the .visit_informed(...) member function. This function works much like std::visit, but it is a member function (and thus only works on one variant at a time), and it passes an additional compile time metadata argument to the visitor.

sumty::variant<int, std::string, void> v{"hello"};

v.visit_informed([](auto& value, auto info) {
    static constexpr size_t index = info.index;
    using type = typename decltype(info)::type;

    // ...
});

The info argument in the example above is an empty type that provides compile time information describing the index and type of the alternative being visited, which can be used in if constexpr conditions, as template arguments, or for any other compile time use case. Usage of std::visit on std::variant often need this information but requires manual metaprogramming and/or book keeping by the user.

Also note that, unlike the standard library, sumty implements the "overload pattern" as a utility. Many consider usage of this pattern a best practice when using std::variant, but without sumty it has to be implemented manually by the user.

sumty::variant<int, std::string, std::vector<int>> v{"hello"};

sumty::visit(sumty::overload {
    [](int value) {
        // visit `int`
    },
    [](const std::string& value) {
        // visit `std::string`
    },
    [](const std::vector<int>& value) {
        // visit `std::vector<int>`
    }
}, v);

Visiting a variant that contains a void alternative will pass an instance of void_t to the visitor for that alternative, instead of passing nothing. This ensures that a generic parameter can be used in the visitor, even if the alternative might be void, rather than having to have a separate overload that expects no argument.

sumty::variant<void, int> v{};

sumty::visit([](auto value) {
    // value is either `void_t` or `int`
}, v);

sumty::option

API Docs

option is a type that contains "some" value or "none". It should be a drop in replacement of std::optional, except that the "some" type can additionally be void or a reference (lvalue or rvalue).

sumty::optional<T> is internally represented as a sumty::variant<void, T>. As such, it benefits from all the same size optimizations that the variant does. option<void> is the same size as a bool since it represents no extra information other than being "some" or "none". An option of an empty type, such as option<std::monostate> is also the size of a bool, since the empty type need not occupy any extra space. An option<T&> is the size of a pointer, since address 0 can be used to represent "none" while all other addresses signify both "some" and the address value of the reference.

sumty::option adds a variety of monadic member functions that are not available for std::optional under any C++ standard version. Below is a listing of these as well as the monadic functions that are available for std::optional, but see the API docs for a detailed explanation of each.

Function Description
.value_or(default) Gets the "some" value or returns a default value if "none"
.value_or_else(fn) Gets the "some" value or returns the result of invoking a callable if "none"
.some_or(default) Identical to .value_or(default)
.some_or_else(fn) Identical to .value_or_else(fn)
.ok_or(error) Returns the "some" value as an ok result, or returns the provided error if "none"
.ok_or_else(fn) Returns the "some" value as an ok result, or returns the result of invoking a callable as an error if "none"
.error_or(ok) Returns the "some" value as an error result, or returns the provided ok value if "none"
.error_or_else(fn) Returns the "some" value as an error result, or returns the result of invoking a callable as an ok value if "none"
.ref() Returns an option of a reference to the original "some" value (option<T> -> option<T&>)
.cref() Returns an option of a const reference to the original "some" value (option<T> -> option<const T&>)
.and_then(fn) If the option is "some", invokes the callable and returns the result. Otherwise, returns "none"
.transform(fn) Returns a new option containing the result of passing the source "some" value to the callable. The returned option is "none" if the source option was "none".
.map(fn) Identical to .transform(fn).
.or_else(fn) Returns the original option if it is "some", otherwise returns the result of invoking the callable.
.flatten() Converts an option<option<T>> into option<T>.
.flatten_all() Same as .flatten() but removes all extra layers of option instead of just the first.
.filter(fn) Returns a new option that is "none" if either the original option was "none" or the result of passing the "some" value to the callable is "false". Otherwise returns the original option.
.transpose() Converts an option<result<T, E>> to a result<option<T>, E>.

sumty also provides visit and visit_informed functions on all sum types. See the overview of sumty::variant for details. Additionally, the free functions sumty::get and sumty::get_if work on all sumty sum types, including sumty::option. These functions behave as if the sumty::option<T> is actually a sumty::variant<void, T>.

sumty::option also works in conjunction with sumty::none_t and sumty::some<T>(...). none_t is essentially the same as std::nullopt_t. An option can be set from and compared with an instance of none_t, sumty::none. However, std::nullopt can also be used for the same purpose. sumty::some<T>(...) is essentially the same as std::make_optional<T>(...). some<T>(...) returns an option<T> with the "some" value of type T constructed with the passed arguments.

sumty::option<std::string> get_str_if(bool condition) {
    if (condition) {
        // returns the string "aaaaa"
        return sumty::some<std::string>(5, 'a');
    } else {
        return sumty::none;
    }
}

void test() {
    auto opt = get_str_if(true);

    assert(opt != sumty::none);
    assert(opt != std::nullopt);

    opt = sumty::none;

    assert(opt == sumty::none);
    assert(opt == std::nullopt);
}

sumty::option<T&> also behaves like non-owning smart pointer. It can be assigned from a raw pointer, T*, where a value of nullptr will set the option to "none", and otherwise set the value to contain a refrence to the value pointed to by the pointer. Like with sumty::none_t and std::nullopt_t, sumty::option<T&> can additionally be assigned from and compared with std::nullptr_t.

sumty::option<T&> opt{};

assert(opt == nullptr);

int i = 42;
opt = &i;

assert(opt == &i);

sumty::result

API Docs

result is a type that contains either an "ok" value or an "error" value. Except that it does not have any interaction with std::unexpected, sumty::result should be a drop in replacement for std::exepcted. Instead of std::unexpected, sumty::result<never, E> is used. Like with the other sum types of sumty, both the "ok" type and the "error" type can be void or a reference (lvalue or rvalue).

sumty::result<T, E> is internally represented as a sumty::variant<T, E>. As such, it benefits from all the same size optimizations that the variant does. result<void, void> is the same size as a bool since it represnts no extra information other than being "ok" or "error". Empty types used for the "ok" or "error" types have the same effect. If one of the alternatives is an lvalue reference and the other is void or an empty type, such as sumty::result<int&, std::monostate>, the size of the result is the same as a pointer.

sumty::result adds a variety of monadic member functions that are not available for std::expected under any C++ standard version. Below is a listing of these as well as the monadic functions that are available for std::expected, but see the API docs for detailed explanation of each.

Function Description
.value_or(default) Gets the "ok" value or returns a default value if "error"
.value_or_else(fn) Gets the "ok" value or returns the result of invoking a callable if "error"
.ok_or(default) Identical to .value_or(default)
.ok_or_else(fn) Identical to .value_or_else(fn)
.and_then(fn) If the result is "ok", invokes the callable and returns the result. Otherwise, returns "error"
.transform(fn) Returns a new result containing the result of passing the source "ok" value to the callable. The returned result is "error" if the source result was "error".
.map(fn) Identical to .transform(fn).
.transform_error(fn) Returns a new result containing the result of passing the source "error" value to the callable. The returned result is "ok" if the source result was "ok".
.map_error(fn) Identical to .transform_error(fn).
.or_else(fn) Returns the original result if it is "ok", otherwise returns the result of invoking the callable.
.flatten() Converts a result<result<T, E>, E> into result<T, E>.
.flatten_all() Same as .flatten() but removes all extra layers of result instead of just the first.
.transpose() Converts a result<option<T>, E> to an option<result<T, E>>.
.invert() Converts a result<T, E> into a result<E, T>.
.ref() Returns a result of a reference to the original "ok" or "error" value (result<T, E> -> result<T&, E&>)
.cref() Returns a result of a const reference to the original "ok" or "error" value (result<T, E> -> result<const T&, const E&>)
.or_none() Returns an option containing the "ok" value as a "some" value, or else returns "none" (result<T, E> -> option<T>)
.ok_or_none() Identical to .or_none()
.error_or_none() Returns an option containing the "error" value as a "some" value, or else returns "none" (result<T, E> -> option<E>)

sumty also provides visit and visit_informed functions on all sum types. See the overview of sumty::variant for details. Additionally, the free functions sumty::get and sumty::get_if work on all sumty sum types, including sumty::result. These functions behave as if the sumty::result<T, E> is actually a sumty::variant<T, E>.

sumty::result also works in conjunction with sumty::ok<T>(...) and sumty::error<T>(...). These functions return an result<T, never> or result<never, T>, respectively. These types can be implicitly converted to a result of the matching "ok" or "error" type. The arguments passed to these functions passed to the constructor of the "ok" or "error" type to instantiate the value. However, plain values also can implicitly convert to an "ok" result, so error<T>(...) will be used more frequently than ok<T>(...).

sumty::result<int, std::string> get_value_if(bool condition) {
    if (condition) {
        // return 42; also works
        return ok<int>(42);
    } else {
        return error<std::string>("oh no");
    }
}

sumty::error_set

API Docs

error_set is nearly identical to variant. The most apparent difference is that each alternative of the set must be unique. A sumty::variant<int, int> is fine, but a sumty::error_set<int, int> would not compile. However, the most important difference, is an error_set can be converted to another error_set whose alternatives are a non- strict superset of the source. The list below shows some possible conversions. Assume that each myerr_* is a distinct type.

  • error_set<myerr_1> -> error_set<myerr_1, myerr_2>
  • error_set<myerr_1, myerr_2> -> error_set<myerr_2, myerr_1>
  • error_set<myerr_1, myerr_3> -> error_set<myerr_3, myerr_2, myerr_1>
  • myerr_2 -> error_set<myerr_1, myerr_2, myerr_3>

As the name implies, sumty::error_set is intended to represent a set of possible errors. Specifically, it is meant to be used as the "error" type of a sumty::result. All the above conversion examples also apply to result types with error_set "error" types. For example, sumty::result<int, myerr_1> converts to sumty::result<int, sumty::error_set<myerr_1, myerr_2, myerr_3>>, and sumty::result<int, sumty::error_set<myerr_1, myerr_3>> converts to sumty::result<int, sumty::error_set<myerr_3, myerr_2, myerr_1>>.

These properties allow for simplified error propagation when using sumty::result. The example below shows how these might be used in practice.

// specific error types
struct neg_int_error {};
struct int_parse_error {};
struct int_overflow_error {};

// union of the above error types
using my_errors = sumty::error_set<
    neg_int_error,
    int_parse_error,
    int_overflow_error
>;

// parse an integer from a string
sumty::result<int, int_parse_error> parse_int(std::string_view str);

// calculate the absolute value of an integer
sumty::result<int, int_overflow_error> abs_value(int x);

// calculate the integer square root
sumty::result<int, neg_int_error> isqrt(int x);

sumty::result<int, my_errors> my_algorithm(std::string_view str) {
    auto parsed = parse_int(str);
    if (parsed.is_error()) { return parsed.prop_error(); }

    auto positive = abs_value(*parsed);
    if (positive.is_error()) { return positive.prop_error(); }

    return isqrt(*positive);
}

The value returned from my_algorithm is able to describe all the possible errors that could occur during exuction of the function, but no special effort was required to to convert the internal errors into the error type returned by my_algorithm.

Like all other sum types in sumty, sumty::error_set has the various size optimizations for void, lvalue references, and empty types. However, void and lvalue references will not likely be used very frequently. Where error_set shines is with empty types. When all the error types of an error_set are empty types, error_set becomes a sort of dynamically defined enum, where each error type designates a value of the enum. That is to say, the empty error types need not take up any extra space, so the error_set is only the size of the smallest integer needed to count the error types, which in the vast majority of cases is uint8_t. But unlike an normal enum, error_set types can be converted to other error_set types by the subset/superset rules defined earlier.

Otherwise, sumty::error_set has an identical API to sumty::variant.

sumty::never

API Docs

never is a type that can never be instantiated without invoking undefined behavior. In the type theory sense, never is the empty set type, as there are no possible values an instance of the type can have. This is different from void, since void conceptually is a unit type, meaning it has one valid value (ignoring language limitations disallowing variables and members of type void). A function that returns sumty::never can never return, a function that has a sumty::never argument can never be called, and a struct or class with a member of type never can never be instantiated.

sumty's sum types optimize specially around alternatives of type never, and types that inherit from never. Since such alternatives can never be instantiated, sum types in sumty behave as if the alternative doesn't exit, where possible. sumty::variant<int, never> is the same size as an int. Because the never alternative can't be instantiated, int is the only possible alternative. As such, there isn't even a need for a discriminant, and the variant can just be a wrapper around the int alternative. Similarly, sumty::variant<void, never> and sumty::variant<std::monostate, never> are both empty types.

One of the primary use cases for sumty::never is as an error type for a sumty::result. A result with a never error type can only ever be an ok value, so the result becomes just a wrapper around the ok value. A use case for this might be an API that requires that a user defined function return a sumty::result, but not require any particular error type. If the user's function has no error code paths, they can use never as the error type to eek out an extra bit of optimization.

// This is likely to be as optimal as returning `int` after optimization passes
sumty::result<int, never> get_value() {
    return 42;
}

sumty::variant<> and sumty::error_set<> are also of type sumty::never. Specifically, they publically inherit from sumty::never. So, for example, sumty::result<int, sumty::error_set<>> is also just a wrapper around int. Similarly, a sum type that is made up entirely of never alternatives is also a never type. Thus, sumty::result<never, never>, sumty::variant<never, never>, and sumty::error_set<never, never> are also examples of never types.

Despite being impossbile to instantiate, sumty::never is still copyable and moveable. That is, the required constructors and assignment operators for never to be copyable and moveable are defined. However, undefined behavior has to be invoked to get to the point where they are called, and calling them also explicitly invokes undefined behavior. This ensures that sum types with a never alternative aren't prevented from being copyable or moveable by virtue of having such an alternative.

sumty::never also is conceptually implicitly convertible to any other type. This allows never to be used seamlessly within sum types. For example, sumty::result<T, never> can be implicitly converted to sumty::result<T, E>, for any error type E.

Project Integration

The quickest, easiest, and most build system agnostic way to start using sumty is to simply add the include directory of this repo to your include path. Depending on your compiler, you may also need to enable the C++20 standard, since sumty requires C++20 or later.

CMake Integration

If your project is CMake based, sumty can either be pulled in as a subproject, or, if sumty is installed, it can be imported using find_package(sumty). In either case, the sumty::sumty target will be made availble to link into your targets. This will add the appropriate include directories to your target(s) as well as enforce that the C++20 standard is being used.

Below is an example of using find_package to import sumty.

find_package(sumty REQUIRED)

add_executable(app app.cpp)

target_link_libraries(app PRIVATE sumty::sumty)

Below is an example of pulling in sumty using CMake's FetchContent feature.

include(FetchContent)

FetchContent_Declare(
    sumty
    GIT_REPOSITORY https://github.com/Vociferix/sumty
    GIT_TAG v0.1.0 # or a commit hash
)
FetchContent_MakeAvailable(sumty)

add_executable(app app.cpp)

target_link_libraries(app PRIVATE sumty::sumty)

Below is an example of using a vendored sumty repository. Vendoring could mean having sumty as a git submodule, or it might be copied into your project.

add_subdirectory(sumty) # use the appropriate path for your project organization

add_executable(app app.cpp)

target_link_libraries(app PRIVATE sumty::sumty)

Other Build System Integration

Some build systems, such as Meson, have built-in support for CMake based dependencies. In those cases, see the documentation for your build system. If your build system does not provide CMake support, simply add the sumty include directory to your include path, as explained previously.

About

Better sum types for C++

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published