3
\$\begingroup\$

Introduction

The following code is an implementation of, in ambition, RFC 4648 compliant Base16, Base32 and Base64 conversions as range adaptors and constrained algorithms in C++23. It can be used at compile time or at run time.

I'll take any feedback, but I am especially interested in feedback from people who have experience of std::ranges, functional programming and lazy evaluation. I have not checked any performance aspect yet, but I have tried to make the implementation efficient.

I am also interested in any feedback on RFC 4648 compliance.

A common algorithm

This implementation features a single algorithm for encoding, and a single algorithm for decoding. This comes from the realization that Base16, Base32 and Base64 really are a single encoding/decoding, parametrized by an alphabet whose size is a power of 2. From that size, we can calculate:

  • The alphabet symbol width. It is the logarithm in base 2 of the alphabet length: 4 for Base16, 5 for Base32 and 6 for Base64.
  • The block size in bits. It is the least common multiple of the symbol width and eight: 8 for Base16, 40 for Base32 and 24 for Base64.
  • The number of octets and alphabet symbols in a block: 1 and 2 for Base16, 5 and 8 for Base32, 3 and 4 for Base64.

Padding is used during encoding if the number of octets to encode is not a multiple of the block size. That can of course never happen for Base16, but we can still use a common encoding algorithm. It will just never generate padding for Base16 because the conditions for padding will just never be fulfilled in that case.

On the decoding-side, one single algorithm too, which will detect a number of possible errors:

  1. Illegal character
  2. Missing character(s)
  3. Illegal padding
  4. Non-canonical encoding.

Error 1 is trivial.

Error 2 is when the total number of character symbols is not a multiple of the block size. For Base16, that happens any time we try to decode an odd number of alphabet symbols.

Error 3 is when padding characters ("=") are found where they should not be. For Base16, that is: everywhere (padding is always illegal in the case of Base16).

Error 4 exists because when padding is used, typically, some encoded bits will not correspond to any decoded bits. Those are supposed to always be zero. Otherwise, the encoding is deemed non-canonical and, like for other errors, the input is rejected.

Range adaptor for encoding, constrained algorithm for decoding

Because encoding cannot fail, a range adaptor, which can be part of a range pipeline, is a great solution. On the other hand, range adaptors are not well adapted to reporting failure. For that, constrained algorithms, which can return anything, are much better. They can for instance return a std::expected type, which is perfect to indicate several possible failures, and this is the choice I have made here (inspired by that review).

I have tried to follow the standard library API conventions as closely as possible in this implementation.

Range concepts and limitations

I have tried to support all kinds of ranges in this implementation, but have run into a limitation on the encoding side, for at least as long as views::cache_last has not been released. This is because encoding, basically, splits the input into chunks (of three octets, for instance, in Base64) and transforms each of those chunks into a chunk of alphabet symbols (four in the case of Base64). Except in the case of Base16, most such symbols are calculated from more than one input octet. The easiest way to achieve that is to apply some kind of multipass, which is not available for input ranges (there are other ways, but they either prevent from reusing standard range adaptors, or have some other drawbacks, like not preserving forward_range properties).

Based on our overall goals and the previous limitation, this implementation, for:

For an implementation that support input_range Base16 encoding, see a Base16-only implementation that this implementation is rooted in.

Some highlights

A few examples of what this implementation provides, focused on Base64:

    // RFC 4648 test vectors ("foobar" in ASCII)

    static_assert(
        equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | encode, "Zm9vYmFy"sv));
    static_assert(equal(decode("Zm9vYmFy"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | to_bytes));

   // "Many hands make light work." in ASCII

    constexpr auto many_etc =
        "\x{4D}\x{61}\x{6E}\x{79}\x{20}\x{68}\x{61}\x{6E}\x{64}\x{73}\x{20}\x{6D}\x{61}\x{6B}\x{65}\x{20}"sv
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}\x{2E}"sv;
    static_assert(
        std::ranges::random_access_range<decltype(many_etc | encode)>);
    static_assert(std::ranges::sized_range<decltype(many_etc | encode)>);
    static_assert(std::ranges::size(many_etc | encode) == 36);

    // Error handling

    static_assert(not decode("TWFuT"sv).has_value() and
                  decode("TWFuT"sv).error() ==
                      decodexx_error::missing_character);
    static_assert(not decode("bGlnaHQgd2=="sv).has_value() and
                  decode("bGlnaHQgd2=="sv).error() ==
                      decodexx_error::non_canonical);

Source code

Quite extensive unit tests are provided. Also: run on godbolt.

#include <algorithm>
#include <cassert>
#include <expected>
#include <functional>
#include <numeric>
#include <ranges>
#include <sstream>
#include <string_view>

enum class decodexx_error : signed char {
    illegal_character,
    missing_character,
    illegal_padding,
    non_canonical,
};

template <typename I, typename O>
struct decodexx_error_result {
    std::ranges::in_out_result<I, O> in_out_result;
    decodexx_error error;
};

namespace detail {
namespace basexx {

constexpr auto padding = '=';

enum class identifier : unsigned char {
    base16,
    base32,
    base32hex,
    base64,
};

template <identifier id>
struct alphabet {};

template <identifier id>
inline constexpr auto alphabet_v = alphabet<id>::value;

template <>
struct alphabet<identifier::base16> {
    static constexpr auto value = std::string_view{"0123456789ABCDEF"};
};

template <>
struct alphabet<identifier::base32> {
    static constexpr auto value =
        std::string_view{"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"};
};

template <>
struct alphabet<identifier::base32hex> {
    static constexpr auto value =
        std::string_view{"0123456789ABCDEFGHIJKLMNOPQRSTUV"};
};

template <>
struct alphabet<identifier::base64> {
    static constexpr auto value = std::string_view{
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"};
};

template <identifier id>
inline constexpr std::size_t alphabet_size{alphabet_v<id>.size()};

template <identifier id>
struct shift_register {};

template <identifier id>
using shift_register_t = shift_register<id>::type;

template <>
struct shift_register<identifier::base16> {
    using type = unsigned char;
};

template <identifier id>
    requires(id == identifier::base32 or id == identifier::base32hex)
struct shift_register<id> {
    using type = unsigned long long;
};

template <>
struct shift_register<identifier::base64> {
    using type = unsigned long;
};

template <identifier id>
struct alphabet_properties {
    static constexpr int width{std::countr_zero(alphabet_size<id>)};
    static constexpr int octet_width{8};
    static constexpr int block_width{std::lcm(width, octet_width)};
    static constexpr int block_num_octets{block_width / octet_width};
    static constexpr int block_num_chars{block_width / width};

    static_assert(static_cast<int>(sizeof(shift_register_t<id>)) >=
                  block_num_octets);
};

template <identifier id>
struct encode : std::ranges::range_adaptor_closure<encode<id>> {
    template <std::ranges::viewable_range R>
        requires(
            std::ranges::forward_range<R> and
            std::convertible_to<std::ranges::range_reference_t<R>, std::byte>)
    constexpr auto operator()(R &&r) const {
        using props = basexx::alphabet_properties<id>;

        return std::views::cartesian_product(
                   std::forward<R>(r) |
                       std::views::chunk(props::block_num_octets),
                   std::views::iota(0,
                                    static_cast<int>(props::block_num_chars)) |
                       std::views::reverse) |
               std::views::transform([](auto indexed_chunk) -> char {
                   auto [chunk, i] = indexed_chunk;
                   auto j = props::block_num_octets - 1;
                   unsigned char c{};
                   for (const auto octet : chunk) {
                       c |= std::rotr(
                           static_cast<basexx::shift_register_t<id>>(octet),
                           (props::width * i) - (props::octet_width * j--));
                   }
                   return i < (props::block_num_chars -
                               (props::octet_width *
                                    (props::block_num_octets - j - 1) +
                                props::width - 1) /
                                   props::width)
                              ? basexx::padding
                              : alphabet_v<id>.at(c & (alphabet_size<id> - 1));
               });
    }
};

}  // namespace basexx

namespace decodexx {

template <basexx::identifier id>
class max_size {
    static constexpr auto input_size_to_max_size(size_t input_size)
        -> std::expected<size_t, decodexx_error> {
        using props = basexx::alphabet_properties<id>;

        if ((input_size % props::block_num_chars) != 0) {
            return std::unexpected{decodexx_error::missing_character};
        }

        return input_size / props::block_num_chars * props::block_num_octets;
    }

   public:
    template <typename R>
        requires(std::ranges::sized_range<R> or std::ranges::forward_range<R>)
    // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward)
    constexpr auto operator()(R &&r) const
        -> std::expected<size_t, decodexx_error> {
        if constexpr (std::ranges::sized_range<R>) {
            return input_size_to_max_size(std::ranges::size(r));
        } else {
            return input_size_to_max_size(std::ranges::distance(r));
        }
    }
};

}  // namespace decodexx

namespace basexx {

template <identifier id>
struct try_decode {
    template <std::input_iterator I, std::sentinel_for<I> S,
              std::weakly_incrementable O, typename Proj = std::identity>
        requires(std::convertible_to<std::indirect_result_t<Proj, I>, char> and
                 std::indirectly_writable<O, std::byte>)
    constexpr auto operator()(I first, S last, O result, Proj proj = {}) const
        -> std::expected<std::ranges::in_out_result<I, O>,
                         decodexx_error_result<I, O>> {
        using namespace std::views;

        using lookup_t =
            std::array<unsigned char,
                       std::numeric_limits<unsigned char>::max() + 1>;

        // The following flags would not work for any alphabet size, but they
        // work up to 64, which is enough for us.
        static constexpr auto is_valid = 0x40;
        static constexpr auto is_padding = 0x80;

        static constexpr auto lookup = [] -> lookup_t {
            lookup_t a{};
            unsigned char i{};
            for (const unsigned char c : basexx::alphabet_v<id>) {
                a.at(c) = is_valid | i++;
            }
            a.at(static_cast<unsigned char>('=')) = is_padding | is_valid;
            return a;
        }();

        auto make_error_result =
            [first = std::move(first),
             result = std::move(result)](decodexx_error error) mutable {
                return std::unexpected{decodexx_error_result<I, O>{
                    .in_out_result = {std::move(first), std::move(result)},
                    .error = error,
                }};
            };

        while (first != last) {
            using props = alphabet_properties<id>;
            basexx::shift_register_t<id> shift_register{};
            auto num_padding = 0;
            auto i = 0;
            for (i = 0; i < props::block_num_chars and first != last;
                 ++i, ++first) {
                const auto bitset = lookup.at(
                    static_cast<unsigned char>(std::invoke(proj, *first)));
                if (not(bitset & is_valid)) {
                    return make_error_result(decodexx_error::illegal_character);
                }
                const auto bitset_is_padding = static_cast<int>(bitset >> 7);
                if ((i < 2 and bitset_is_padding) or
                    (num_padding and not bitset_is_padding)) {
                    return make_error_result(decodexx_error::illegal_padding);
                }
                num_padding += bitset_is_padding;
                shift_register = (shift_register << props::width) |
                                 (bitset & (alphabet_size<id> - 1));
            }
            if (i != props::block_num_chars) {
                return make_error_result(decodexx_error::missing_character);
            }
            const int num_missing_octets{
                (num_padding * props::width + props::octet_width - 1) /
                props::octet_width};
            if (shift_register &
                ((decltype(shift_register){1}
                  << (num_missing_octets * props::octet_width)) -
                 1)) {
                return make_error_result(decodexx_error::non_canonical);
            }
            for (i = 0; i < props::block_num_octets - num_missing_octets; ++i) {
                *result++ = static_cast<std::byte>(
                    shift_register >>
                    ((props::block_num_octets - i - 1) * props::octet_width));
            }
        }

        return std::ranges::in_out_result<I, O>{std::move(first),
                                                std::move(result)};
    }

    template <std::ranges::input_range R, std::weakly_incrementable O,
              typename Proj = std::identity>
        requires(std::convertible_to<
                     std::indirect_result_t<Proj, std::ranges::iterator_t<R>>,
                     char> and
                 std::indirectly_writable<O, std::byte>)
    // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward)
    constexpr auto operator()(R &&r, O result, Proj proj = {}) const
        -> std::expected<
            std::ranges::in_out_result<std::ranges::borrowed_iterator_t<R>, O>,
            decodexx_error_result<std::ranges::borrowed_iterator_t<R>, O>> {
        return (*this)(std::ranges::begin(r), std::ranges::end(r),
                       std::move(result), std::move(proj));
    }
};

template <identifier id, template <typename> typename C>
struct try_decode_to {
    template <std::ranges::input_range R, typename Proj = std::identity>
        requires std::convertible_to<
            std::indirect_result_t<Proj, std::ranges::iterator_t<R>>, char>
    constexpr auto operator()(R &&r, Proj proj = {}) const
        -> std::expected<C<std::byte>, decodexx_error>;
};

template <identifier id>
struct try_decode_to<id, std::vector> {
    template <std::ranges::input_range R, typename Proj = std::identity>
        requires std::convertible_to<
            std::indirect_result_t<Proj, std::ranges::iterator_t<R>>, char>
    constexpr auto operator()(R &&r, Proj proj = {}) const
        -> std::expected<std::vector<std::byte>, decodexx_error> {
        std::vector<std::byte> v{};

        return try_decode<id>{}(std::forward<R>(r),
                                std::back_insert_iterator{v}, std::move(proj))
            .transform(
                [v = std::move(v)](auto &&) mutable { return std::move(v); })
            .transform_error([](const auto &error) { return error.error; });
    }

    template <std::ranges::input_range R, typename Proj = std::identity>
        requires(std::convertible_to<
                     std::indirect_result_t<Proj, std::ranges::iterator_t<R>>,
                     char> and
                 (std::ranges::sized_range<R> or std::ranges::forward_range<R>))
    constexpr auto operator()(R &&r, Proj proj = {}) const
        -> std::expected<std::vector<std::byte>, decodexx_error> {
        const auto size = decodexx::max_size<id>{}(std::forward<R>(r));

        if (not size.has_value()) {
            return std::unexpected{size.error()};
        }

        std::vector<std::byte> v(*size);

        const auto result =
            try_decode<id>{}(std::forward<R>(r), v.begin(), std::move(proj));

        if (result.has_value()) {
            v.resize(result->out - v.begin());
        }

        return result
            .transform(
                [v = std::move(v)](auto &&) mutable { return std::move(v); })
            .transform_error([](const auto &error) { return error.error; });
    }
};

template <identifier id, template <typename> typename C>
    requires std::default_initializable<C<std::byte>>
struct decode_to {
    template <std::ranges::input_range R, typename Proj = std::identity>
        requires std::convertible_to<
            std::indirect_result_t<Proj, std::ranges::iterator_t<R>>, char>
    constexpr auto operator()(R &&r, Proj proj = {}) const -> C<std::byte> {
        return try_decode_to<id, C>{}(std::forward<R>(r), std::move(proj))
            .value_or(C<std::byte>{});
    }
};

}  // namespace basexx

}  // namespace detail

inline constexpr detail::basexx::encode<detail::basexx::identifier::base16>
    encode16{};
inline constexpr detail::basexx::encode<detail::basexx::identifier::base32>
    encode32{};
inline constexpr detail::basexx::encode<detail::basexx::identifier::base32hex>
    encode32hex{};
inline constexpr detail::basexx::encode<detail::basexx::identifier::base64>
    encode64{};

inline constexpr detail::decodexx::max_size<detail::basexx::identifier::base16>
    decode16_max_size{};
inline constexpr detail::decodexx::max_size<detail::basexx::identifier::base32>
    decode32_max_size{};
inline constexpr detail::decodexx::max_size<
    detail::basexx::identifier::base32hex>
    decode64_max_size{};

inline constexpr detail::basexx::try_decode<detail::basexx::identifier::base16>
    try_decode16{};
inline constexpr detail::basexx::try_decode<detail::basexx::identifier::base32>
    try_decode32{};
inline constexpr detail::basexx::try_decode<
    detail::basexx::identifier::base32hex>
    try_decode32hex{};
inline constexpr detail::basexx::try_decode<detail::basexx::identifier::base64>
    try_decode64{};

inline constexpr detail::basexx::try_decode_to<
    detail::basexx::identifier::base16, std::vector>
    try_decode16_to_vector{};
inline constexpr detail::basexx::try_decode_to<
    detail::basexx::identifier::base32, std::vector>
    try_decode32_to_vector{};
inline constexpr detail::basexx::try_decode_to<
    detail::basexx::identifier::base32hex, std::vector>
    try_decode32hex_to_vector{};
inline constexpr detail::basexx::try_decode_to<
    detail::basexx::identifier::base64, std::vector>
    try_decode64_to_vector{};

inline constexpr detail::basexx::decode_to<detail::basexx::identifier::base16,
                                           std::vector>
    decode16_to_vector{};
inline constexpr detail::basexx::decode_to<detail::basexx::identifier::base32,
                                           std::vector>
    decode32_to_vector{};
inline constexpr detail::basexx::decode_to<
    detail::basexx::identifier::base32hex, std::vector>
    decode32hex_to_vector{};
inline constexpr detail::basexx::decode_to<detail::basexx::identifier::base64,
                                           std::vector>
    decode64_to_vector{};

namespace {

using namespace std::string_view_literals;
using std::ranges::equal;

constexpr auto to_bytes = std::views::transform(
    [](auto num) -> std::byte { return static_cast<std::byte>(num); });

void test_encode16() {
    static constexpr auto encode = to_bytes | encode16;

    // RFC 4648 test vectors ("foobar" in ASCII)

    static_assert(equal(""sv | encode, ""sv));
    static_assert(equal("\x{66}"sv | encode, "66"sv));
    static_assert(equal("\x{66}\x{6F}"sv | encode, "666F"sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}"sv | encode, "666F6F"sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}\x{62}"sv | encode, "666F6F62"sv));
    static_assert(
        equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | encode, "666F6F6261"sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | encode,
                        "666F6F626172"sv));
}

void test_decode16() {
    // RFC 4648 test vectors ("foobar" in ASCII)

    static constexpr auto decode = decode16_to_vector;

    static_assert(equal(decode(""sv), ""sv | to_bytes));
    static_assert(equal(decode("66"sv), "\x{66}"sv | to_bytes));
    static_assert(equal(decode("666F"sv), "\x{66}\x{6F}"sv | to_bytes));
    static_assert(equal(decode("666F6F"sv), "\x{66}\x{6F}\x{6F}"sv | to_bytes));
    static_assert(
        equal(decode("666F6F62"sv), "\x{66}\x{6F}\x{6F}\x{62}"sv | to_bytes));
    static_assert(equal(decode("666F6F6261"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | to_bytes));
    static_assert(equal(decode("666F6F626172"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | to_bytes));
}

void test_decode16_error_handling() {
    static constexpr auto decode = try_decode16_to_vector;

    static_assert(not decode("Z123"sv).has_value() and
                  decode("Z123"sv).error() ==
                      decodexx_error::illegal_character);
    static_assert(not decode("\x{00}123"sv).has_value() and
                  decode("\x{00}123"sv).error() ==
                      decodexx_error::illegal_character);
    static_assert(not decode("\x{FF}123"sv).has_value() and
                  decode("\x{FF}123"sv).error() ==
                      decodexx_error::illegal_character);
    static_assert(not decode("A1234"sv).has_value() and
                  decode("A1234"sv).error() ==
                      decodexx_error::missing_character);
    // Note: flagging illegal padding, as opposed to illegal character, could
    // seem strange in the case of Base16, since padding is never needed in that
    // case. But RFC 4648 specifies "=" as the general padding character, so
    // flagging its use in Base16 as illegal padding is not a violation of the
    // standard. On the other hand, the non-canonical error cannot occur in
    // Base16, because it can only occur if actual padding is used.
    static_assert(not decode("1=34"sv).has_value() and
                  decode("1=34"sv).error() == decodexx_error::illegal_padding);
    static_assert(not decode("12=4"sv).has_value() and
                  decode("12=4"sv).error() == decodexx_error::illegal_padding);
}

void test_encode32() {
    static constexpr auto encode = to_bytes | encode32;

    // RFC 4648 test vectors ("foobar" in ASCII)

    static_assert(equal(""sv | encode, ""sv));
    static_assert(equal("\x{66}"sv | encode, "MY======"sv));
    static_assert(equal("\x{66}\x{6F}"sv | encode, "MZXQ===="sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}"sv | encode, "MZXW6==="sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}\x{62}"sv | encode, "MZXW6YQ="sv));
    static_assert(
        equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | encode, "MZXW6YTB"sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | encode,
                        "MZXW6YTBOI======"sv));
}

void test_decode32() {
    // RFC 4648 test vectors ("foobar" in ASCII)

    static constexpr auto decode = decode32_to_vector;

    static_assert(equal(decode(""sv), ""sv | to_bytes));
    static_assert(equal(decode("MY======"sv), "\x{66}"sv | to_bytes));
    static_assert(equal(decode("MZXQ===="sv), "\x{66}\x{6F}"sv | to_bytes));
    static_assert(
        equal(decode("MZXW6==="sv), "\x{66}\x{6F}\x{6F}"sv | to_bytes));
    static_assert(
        equal(decode("MZXW6YQ="sv), "\x{66}\x{6F}\x{6F}\x{62}"sv | to_bytes));
    static_assert(equal(decode("MZXW6YTB"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | to_bytes));
    static_assert(equal(decode("MZXW6YTBOI======"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | to_bytes));
}

void test_decode32_error_handling() {
    static constexpr auto decode = try_decode32_to_vector;

    static_assert(not decode("MYVA\x{00}LUE"sv).has_value() and
                  decode("MYVA\x{00}LUE"sv).error() ==
                      decodexx_error::illegal_character);
    static_assert(not decode("MYVA\x{FF}LUE"sv).has_value() and
                  decode("MYVA\x{FF}LUE"sv).error() ==
                      decodexx_error::illegal_character);
    static_assert(not decode("MYV\x{FF}LUE"sv).has_value() and
                  decode("MYV\x{FF}LUE"sv).error() ==
                      decodexx_error::missing_character);
    static_assert(not decode("M=YV\x{FF}LUE"sv).has_value() and
                  decode("M=YV\x{FF}LUE"sv).error() ==
                      decodexx_error::illegal_padding);
    static_assert(not decode("MY==A==="sv).has_value() and
                  decode("MY==A==="sv).error() ==
                      decodexx_error::illegal_padding);
    static_assert(not decode("MZ======"sv).has_value() and
                  decode("MZ======"sv).error() ==
                      decodexx_error::non_canonical);
    static_assert(not decode("M7======"sv).has_value() and
                  decode("M7======"sv).error() ==
                      decodexx_error::non_canonical);
    static_assert(not decode("MY======A"sv).has_value() and
                  decode("MY======A"sv).error() ==
                      decodexx_error::missing_character);
}

void test_encode32hex() {
    static constexpr auto encode = to_bytes | encode32hex;

    // RFC 4648 test vectors ("foobar" in ASCII)

    static_assert(equal(""sv | encode, ""sv));
    static_assert(equal("\x{66}"sv | encode, "CO======"sv));
    static_assert(equal("\x{66}\x{6F}"sv | encode, "CPNG===="sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}"sv | encode, "CPNMU==="sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}\x{62}"sv | encode, "CPNMUOG="sv));
    static_assert(
        equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | encode, "CPNMUOJ1"sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | encode,
                        "CPNMUOJ1E8======"sv));
}

void test_decode32hex() {
    // RFC 4648 test vectors ("foobar" in ASCII)

    static constexpr auto decode = decode32hex_to_vector;

    static_assert(equal(decode(""sv), ""sv | to_bytes));
    static_assert(equal(decode("CO======"sv), "\x{66}"sv | to_bytes));
    static_assert(equal(decode("CPNG===="sv), "\x{66}\x{6F}"sv | to_bytes));
    static_assert(
        equal(decode("CPNMU==="sv), "\x{66}\x{6F}\x{6F}"sv | to_bytes));
    static_assert(
        equal(decode("CPNMUOG="sv), "\x{66}\x{6F}\x{6F}\x{62}"sv | to_bytes));
    static_assert(equal(decode("CPNMUOJ1"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | to_bytes));
    static_assert(equal(decode("CPNMUOJ1E8======"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | to_bytes));
}

void test_encode64() {
    static constexpr auto encode = to_bytes | encode64;

    // "Man" in ASCII

    static_assert(equal("\x{4D}\x{61}\x{6E}"sv | encode, "TWFu"sv));
    static_assert(equal("\x{4D}\x{61}"sv | encode, "TWE="sv));
    static_assert(equal("\x{4D}"sv | encode, "TQ=="sv));

    // "light work." in ASCII

    static_assert(equal(
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}\x{2E}"sv |
            encode,
        "bGlnaHQgd29yay4="sv));
    static_assert(
        equal("\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}"sv |
                  encode,
              "bGlnaHQgd29yaw=="sv));
    static_assert(equal(
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}"sv | encode,
        "bGlnaHQgd29y"sv));
    static_assert(
        equal("\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}"sv | encode,
              "bGlnaHQgd28="sv));
    static_assert(equal("\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}"sv | encode,
                        "bGlnaHQgdw=="sv));

    // "Many hands make light work." in ASCII

    constexpr auto many_etc =
        "\x{4D}\x{61}\x{6E}\x{79}\x{20}\x{68}\x{61}\x{6E}\x{64}\x{73}\x{20}\x{6D}\x{61}\x{6B}\x{65}\x{20}"sv
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}\x{2E}"sv;
    static_assert(
        equal(many_etc | encode, "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu"sv));

    // RFC 4648 test vectors ("foobar" in ASCII)

    static_assert(equal(""sv | encode, ""sv));
    static_assert(equal("\x{66}"sv | encode, "Zg=="sv));
    static_assert(equal("\x{66}\x{6F}"sv | encode, "Zm8="sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}"sv | encode, "Zm9v"sv));
    static_assert(equal("\x{66}\x{6F}\x{6F}\x{62}"sv | encode, "Zm9vYg=="sv));
    static_assert(
        equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | encode, "Zm9vYmE="sv));
    static_assert(
        equal("\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | encode, "Zm9vYmFy"sv));
}

void test_encode64_preservation_of_range_properties() {
    static constexpr auto encode = to_bytes | encode64;

    // "Many hands make light work." in ASCII

    constexpr auto many_etc =
        "\x{4D}\x{61}\x{6E}\x{79}\x{20}\x{68}\x{61}\x{6E}\x{64}\x{73}\x{20}\x{6D}\x{61}\x{6B}\x{65}\x{20}"sv
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}\x{2E}"sv;
    static_assert(
        std::ranges::random_access_range<decltype(many_etc | encode)>);
    static_assert(std::ranges::sized_range<decltype(many_etc | encode)>);
    static_assert(std::ranges::size(many_etc | encode) == 36);
}

void test_decode64() {
    static constexpr auto decode = decode64_to_vector;

    // "Man" in ASCII

    static_assert(equal(decode("TWFu"sv), "\x{4D}\x{61}\x{6E}"sv | to_bytes));
    static_assert(equal(decode("TWE="sv), "\x{4D}\x{61}"sv | to_bytes));
    static_assert(equal(decode("TQ=="sv), "\x{4D}"sv | to_bytes));

    // "light work." in ASCII

    static_assert(equal(
        decode("bGlnaHQgd29yay4="sv),
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}\x{2E}"sv |
            to_bytes));
    static_assert(
        equal(decode("bGlnaHQgd29yaw=="sv),
              "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}"sv |
                  to_bytes));
    static_assert(equal(
        decode("bGlnaHQgd29y"sv),
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}"sv | to_bytes));
    static_assert(
        equal(decode("bGlnaHQgd28="sv),
              "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}"sv | to_bytes));
    static_assert(
        equal(decode("bGlnaHQgdw=="sv),
              "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}"sv | to_bytes));

    // "Many hands make light work." in ASCII

    constexpr auto many_etc =
        "\x{4D}\x{61}\x{6E}\x{79}\x{20}\x{68}\x{61}\x{6E}\x{64}\x{73}\x{20}\x{6D}\x{61}\x{6B}\x{65}\x{20}"sv
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}\x{2E}"sv;
    static_assert(equal(decode("TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu"sv),
                        many_etc | to_bytes));

    // RFC 4648 test vectors ("foobar" in ASCII)

    static_assert(equal(decode(""sv), ""sv | to_bytes));
    static_assert(equal(decode("Zg=="sv), "\x{66}"sv | to_bytes));
    static_assert(equal(decode("Zm8="sv), "\x{66}\x{6F}"sv | to_bytes));
    static_assert(equal(decode("Zm9v"sv), "\x{66}\x{6F}\x{6F}"sv | to_bytes));
    static_assert(
        equal(decode("Zm9vYg=="sv), "\x{66}\x{6F}\x{6F}\x{62}"sv | to_bytes));
    static_assert(equal(decode("Zm9vYmE="sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}"sv | to_bytes));
    static_assert(equal(decode("Zm9vYmFy"sv),
                        "\x{66}\x{6F}\x{6F}\x{62}\x{61}\x{72}"sv | to_bytes));
}

void test_decode64_error_handling() {
    static constexpr auto decode = try_decode64_to_vector;

    static_assert(not decode("TWFuT"sv).has_value() and
                  decode("TWFuT"sv).error() ==
                      decodexx_error::missing_character);
    static_assert(not decode("bGlnaHQgd2=="sv).has_value() and
                  decode("bGlnaHQgd2=="sv).error() ==
                      decodexx_error::non_canonical);
    static_assert(not decode("bGlnaH\x{00}gd28="sv).has_value() and
                  decode("bGlnaH\x{00}gd28="sv).error() ==
                      decodexx_error::illegal_character);
    static_assert(not decode("bGlnaH\x{FF}gd28="sv).has_value() and
                  decode("bGlnaH\x{FF}gd28="sv).error() ==
                      decodexx_error::illegal_character);
    static_assert(not decode("bGln=HQgd28="sv).has_value() and
                  decode("bGln=HQgd28="sv).error() ==
                      decodexx_error::illegal_padding);
}

void test_decode64_input_range() {
    std::istringstream many_etc{"TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu"};
    auto many_etc_view = std::views::istream<char>(many_etc);
    assert(equal(
        decode64_to_vector(many_etc_view),
        "\x{4D}\x{61}\x{6E}\x{79}\x{20}\x{68}\x{61}\x{6E}\x{64}\x{73}\x{20}\x{6D}\x{61}\x{6B}\x{65}\x{20}"sv
        "\x{6C}\x{69}\x{67}\x{68}\x{74}\x{20}\x{77}\x{6F}\x{72}\x{6B}\x{2E}"sv |
            to_bytes));
}
void test_decode64_input_range_error_handling() {
    std::istringstream missing_character{"TWFuT"};
    auto missing_character_view = std::views::istream<char>(missing_character);
    const auto missing_char_result =
        try_decode64_to_vector(missing_character_view);
    assert(not missing_char_result.has_value());
    assert(missing_char_result.error() == decodexx_error::missing_character);
}

void test_encode64_decode64() {
    // Note: the try_decode functions return updated input and output iterators
    // when they are successful. If the passed range to decode is an automatic
    // variable, the returned iterators would be dangling. In such a case, even
    // if we use the decode functions, which throw away those iterators, the
    // decoded expression is not a constant expression. In order to avoid that
    // problem, we need to ensure that the encoded range still is in scope when
    // the decoding function returns. Hence the somewhat verbose code below.
    constexpr auto man = "\x{4D}\x{61}\x{6E}"sv;
    constexpr auto encoded = man | to_bytes | encode64;
    static_assert(equal(decode64_to_vector(encoded), man | to_bytes));
}

void test_decode64_encode64() {
    constexpr auto encoded = "TWFu"sv;
    static_assert(equal(decode64_to_vector(encoded) | encode64, encoded));
}

}  // namespace

auto main() -> int {
    test_encode16();
    test_decode16();
    test_decode16_error_handling();
    test_encode32();
    test_decode32();
    test_decode32_error_handling();
    test_encode32hex();
    test_decode32hex();
    // Note: no explicit check for Base32 Hex error handling, since only the
    // alphabet symbols differ from regular Base32.
    test_encode64();
    test_decode64();
    test_decode64_error_handling();
    // "Special functionality" only tested for Base64 to avoid even more
    // verbosity. Base16 and Base32 should give the same results. Also worth
    // noting: input range encoding is at the time of writing not provided,
    // because the best solution to run our algorithm without multi-pass would
    // be to use views::cache_last between views::transform and
    // views::cartesian_product, but that facility at the time of writing is
    // only proposed for standardization
    // (https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2024/p3138r0.html).
    // Also, using views::cache_last will not be sufficient: in order for our
    // implementation not to be ill-formed with input ranges, we will also need
    // to implement a variant of std::transform that does not require
    // equality-preservation (see
    // https://stackoverflow.com/questions/79069912/stdviewschunk-stdviewstransform-inputs-ranges-and-ill-formedness).
    test_encode64_preservation_of_range_properties();
    test_decode64_input_range();
    test_decode64_input_range_error_handling();
    test_encode64_decode64();
    test_decode64_encode64();

    return 0;
}
\$\endgroup\$

2 Answers 2

3
\$\begingroup\$

Design review

Before I get into the review, just a little background context might help explain any oddness that comes up: I used a project like this—a generic base-\$N\$ encoder/decoder—as a teaching project… years ago, long before std::ranges was a thing. In the years since, I’ve mulled over updating the project to modern standards, but still haven’t got around to it. So some of the design suggestions I will be making are based on experience, but some are just vague notions I’ve thought about exploring. So if some suggestions seem silly or outright impossible to implement… that would be why.

A closed enumeration of encodings may be too restrictive

Alright, so the first thing I’m concerned about is hard-coding the set of encoders and alphabets. Your current design uses an enumeration to list the possible encodings:

enum class identifier : unsigned char {
    base16,
    base32,
    base32hex,
    base64,
};

But this is just a subset of the many, many possibilities, like z-base-32 (which I needed recently), or the base-20 encoding used for plus codes (which I may actually need soon). It doesn’t even cover all the encodings in RFC 4648; it doesn’t include the URL-safe version of base64.

What I would suggest instead is using a traits-based design, where users can provide arbitrary traits types for any type of encoding they please. The simplest implementation would just require an alphabet:

struct base16_traits
{
    static inline constexpr auto alphabet = std::array<char, 16>{'0', '1', ... , 'F'};
};

From that you can deduce the character type and encoding width as, basically, range_value_t<base16_traits::alphabet> and size(base16_traits::alphabet) respectively, and from there the number of bytes or characters in a chunk. And that’s all you need.

For a very slightly more complex encoding, you could add a padding character:

struct base32_traits
{
    static inline constexpr auto alphabet = std::array<char, 32>{'A', 'B', ... , '7'};
    static inline constexpr auto padding = '=';
};

Now, to actually make this work, you’ll probably want a concept. Very roughly:

template<typename T>
concept base_encoding_traits =
    requires
    {
        T::alphabet;
        requires random_access_range<decltype(T::alphabet)>;
        // and so on...
    }
    and (
        (not requires { T::padding; })
        or requires
        {
            T::padding;
            requires same_as<T::padding, range_value_t<T::alphabet>>;
            // and so on...
        }
    )
    ;

So now you just do:

template<base_encoding_traits Traits>
struct encode : std::ranges::range_adaptor_closure<encode<Traits>>
{
    // ...

And you can support any kind of binary-to-text encoding based on simple bit grouping with an alphabet (and optional padding).

And then you can go further. In addition to alphabet and padding, you could also have an optional normalize_character() function that takes a character, and returns the normalized form of that character. Why? So that you could implement as (conceptually) static constexpr auto normalize_character(char c) { return std::toupper(c); }, and now you can have a Base16 decoder that can handle both 1A abd 1a as input, and decode both as std::byte(26).

You could go even further. Right now the lion’s share of the logic is in the encode and decode classes. But if we offload it to the traits type, then encode and decode can become a generic encoder/decoder that can handle any binary-to-text scheme.

For example, if the traits classes looked like this:

struct base16_traits
{
    static constexpr auto operator()(std::byte) noexcept -> std::array<char, 2>;
    static constexpr auto operator()(std::array<char, 2>) -> std::expected<std::byte, ...>;
};

struct base64_traits
{
    static constexpr auto operator()(std::array<std::byte, 3>) noexcept -> std::array<char, 4>;
    static constexpr auto operator()(std::array<char, 4>) -> std::expected<std::array<std::byte, 3>, ...>;
};

That’s all a generic encoder needs to know that Base16 encoding turns a byte at a time into 2 characters, and Base64 encoding turns 3 bytes at a time into 4 characters. And that encoding cannot fail in either case, but decoding can.

And you could go even further, and allow for partial encoding without padding. Instead of std::array<Char, N>, you could use a custom type that holds up to \$N\$ characters, and records how many it holds. Basically, std::inplace_vector<Char, N>, if you’re willing to wait for that. Now when you inspect the traits type, you can observe if it takes a std::array<Char, N> or std::inplace_vector<Char, N> in the encoding function, and know whether it accepts partial input for encoding… and you can observe whether it takes std::array<std::byte, M> or std::inplace_vector<std::byte, M> in the decoding function and know whether it accepts partial input for decoding.

For flexibility and efficiency, you could even allow both full and partial decoding with a single traits type:

struct base64_traits
{
    // This function encodes up to 3 bytes, padding if necessary.
    static constexpr auto operator()(std::inplace_vector<std::byte, 3>) noexcept -> std::array<char, 4>;

    // This function decodes exactly 4 chars (which may be padded).
    static constexpr auto operator()(std::array<char, 4>) -> std::expected<std::array<std::byte, 3>, ...>;

    // This function decodes up to 4 chars (which may be padded).
    //
    // Instead of inplace_vector you might want a custom type that can report:
    //  1.  how many bytes were *fully* decoded
    //  2.  how many bytes were *partially* decoded
    static constexpr auto operator()(std::inplace_vector<char, 4>) -> std::expected<std::inplace_vector<std::byte, 3>, ...>;
};

You don’t even need to give up the simplicity of the basic case, where all you need is an alphabet and maybe a padding character. All you need is a wrapper type that takes the simple traits type and produces the more complex case. Indeed, all of the RFC 4648 encodings and many more popular ones—like z-base-32—could be made that way. Or if not a wrapper type, you could have the actual encode and decode types take either a simple traits type or a more complex one.

In fact, you could plan ahead and make this something that can be progressively improved. First start with an encoding_traits concept defined basically as = basic_alphabet_encoding_traits<T>;, which is in turn defined as = basic_unpadded_alphabet_encoding_traits<T>;, which only accepts a simple struct with a static alphabet array. Make a couple of helper types like encoding_alphabet_size<basic_alphabet_encoding_traits Traits>, encoding_character_chunk_size<basic_alphabet_encoding_traits Traits>, and encoding_character_chunk_size<basic_alphabet_encoding_traits Traits> to do the needed size calculations. And then make encode<encoding_traits Traits> and decode<encoding_traits Traits> with that stuff. Later you can expand the basic_alphabet_encoding_traits<T> concept to be = basic_unpadded_alphabet_encoding_traits<T> or basic_padded_alphabet_encoding_traits<T>; and add support for an optional padding character in encode<encoding_traits Traits> and decode<encoding_traits Traits>. And then much later, expand the encoding_traits concept to allow for more complex encoding traits. As you expand capabilities, you have the option to leave the previously-working simpler code completely untouched—it will continue to work as always—or refactor if the amount of code gets to be too much.

Anywho, the main point here is that using an enumeration for a closed set of encodings is too restrictive. Even if you just want to stick basic encodings—those with a fixed alphabet—you can get much more power and flexibility by using the traits pattern instead.

Ranges as a library: 👍🏼! Ranges in a library… 😬

I’ve written elsewhere that while I love the std::ranges library, and use it frequently in my own code, I am much less enthusiastic about using it for library code.

You’ve already seen one reason why: std::ranges basically has no concept of error handling. If an operation can fail, it generally cannot be expressed as a std::ranges-compatible range, view, or RAO.

Another reason why is that std::ranges solutions balance the writing-cost/runtime-cost equation heavily in favour of writing-cost. Writing ranges code is often an absolute pleasure, and it can be very simple to express complex constructions as an elegant chain of views and view adaptors which are “correct by construction”, saving massively on development and debugging costs. However, it is quite often the case that the result is not as efficient as it could be. And the work needed to make it efficient often far outweighs the costs of simply rolling out a bespoke solution.

Please don’t misunderstand me. I am not saying that std::ranges is inefficient. In fact, each individual view adaptor is heavily optimized, and often optimal. It’s the combinations and chains that are less than optimal. And again, this is not the fault of the individual view adaptors. The inefficiency comes from using the pre-packaged adaptors when a more task-specific operation would be better suited.

Let me stop being vague and get concrete. This is the first part of your encode operation, stripped down and focusing on base64 encoding for simplicity:

return cartesian_product(input | chunk(3), iota(0, 4) | reverse) | ...

Now, let’s assume the input is DEADC0DE. input | chunk(3) will read it as chunks of up to 3: so DEA, then DC0, then DE and done. iota(0, 4) generates 0, 1, 2, 3, and then that is viewed through reverse to get 3, 2, 1, 0. This is already not great, because iota(0, N) | reverse is hardly the most efficient way to produce N-1, N-2, …, 0. But it’s about to get much worse.

That’s because the next step is cartesian_product. And what that is generate the product of each chunk group and the 3, 2, 1, 0 sequence. So for the first chunk: (DEA,3), (DEA,2), (DEA,1), (DEA,0). An then the next chunk gives: (DC0,3), (DC0,2), (DC0,1), (DC0,0). That is… a lot.

As for why you did it that way: you need to turn each group of three in the input to a group of four in the output. But the upshot is problematic, because for each of those tuples, the DEA is not a constant, nor is it reused. Instead, for each time, the input is being re-read. So what should be an input of just three characters, you need to read the input range twelve times.

As you note, this is why you need the input to be at least a forward range, not an input range. I don’t think cache_last will save you here either. Depending on where you put it, it will either cache the last input element read (so it will cache the A for the first chunk), or it will cache the last view that comes out of std::ranges::chunk_view (which is probably a std::ranges::take_view). In neither case would it be caching the actual three input elements; you would need a cache_last_n for that. But even with that, doing the cartesian product and all that jazz, going over the data multiple times, will hardly be maximally efficient.

I haven’t experimented yet with this, but there seems to be a much simpler way to re-chunk an input sequence. First you chunk, then you transform the chunk into a chunk of a different size, then you join. Basically, like so:

return chunk(3)
    | transform([](auto&& chunk) { return std::array<char, 4>{}; })
    | join
;

As a concrete example: https://godbolt.org/z/cWo375zz7.

As I say, I haven’t experimented, but I suspect the join view will kill any sized_range transmission. That is because while join can see that the source range—which in this case will be a range of array<char, 4>—has a known size, I don’t think it’s smart enough to realize the source range elements also have a known size… that never changes. Like, it can tell that the results of the transform view are all sized_range… but it doesn’t know that the size is constant, and that each one has the same size.

Can this be fixed? Well, sure, but you would need to write a whole new view that is basically views::join, but checks that the input range is sized, and the input range value type is sized, and the input range value type’s size is a constant expression. Doable? Sure. Worth it? 😬 I don’t really see a lot of use cases for it. YMMV.

std::ranges is fine for end-user code or business logic. For library code… not so much. Providing std::ranges-compatible ranges from a library is fine (providing a decent encode view adaptor would be great)… building a library out of them is likely to be less than ideal.

In my mind, the best way to do this would be to make a custom view—not simply a RACO; an actual view—then make a RACO for that. For example:

template<encode_traits Traits, std::ranges::view V>
requires std::ranges::input_range<V>
    and std::convertible_to<std::ranges::range_reference_t<V>, std::byte>
class encode_view : public std::ranges::view_interface<encode_view<Traits, V>>
{
public:
    constexpr explicit encode_view(V);

    constexpr auto begin() { return /* some iterator type */; }
    constexpr auto end()   { return /* some iterator or sentinel type */; }

    // Can compute the encoded size only if we can get the size of the original view.
    constexpr auto size() requires std::ranges::sized_range<V>;
};

template<encode_traits Traits, std::ranges::viewable_range R>
encode_view(R&&) -> encode_view<Traits, std::views::all_t<R>>;

namespace views {

template<encode_traits Traits>
struct encode_fn : std::ranges::range_adaptor_closure<encode<Traits>>
{
    template<std::viewable_range R>
    requires std::ranges::input_range<R>
        and std::convertible_to<std::ranges::range_reference_t<R>, std::byte>
    constexpr auto operator()(R&&) -> encode_view<Traits, std::views::all_t<R>>;
};

inline constexpr auto encode = encode_fn{};

} // namespace views

The iterators of the view just need to have a base view iterator, a counter to keep track of how many bytes have been read so far (which, for base64, will only be 0, 1, or 2), how many characters have been written so far (which will be 0 or 1 when the bytes read counter is 0, 1 or 2 when the bytes read counter is 1, and 3 (or 4, but then everything wraps to zero for the next chunk) when the bytes read counter is 2)—and maybe you only need one of the two previous counters, maybe if you just count the bits read, or characters written—and of course the encoded character. There’s a lot of boilerplate (yet another fair cop against std::ranges), but the actual code is very, very simple. That should be maximally optimal, and retain all the salient features of the base view (like being random-access or sized or whatevs).

You could probably do this as a RACO, of course, but by the time you’ve written all the complexity inside the RACO function, you’ve pretty much written the code for a custom view already.

Code review

enum class decodexx_error : signed char {
    illegal_character,
    missing_character,
    illegal_padding,
    non_canonical,
};

Specifying the underlying type of an enumeration is generally an anti-pattern. Nine times out of ten, it serves no purpose; doesn’t help in the least. For example, your use case is basically this:

template<typename I, typename O>
struct decodexx_error_result
{
    [[no_unique_address]] I in;
    [[no_unique_address]] O out;
    decodexx_error error;
};

Now, assuming both I and O are just pointers, the size of this struct will probably be… 3 pointers. 🤷🏼 Reason why is because instances of this struct would have to align when placed in an array, so even if the size were 2 pointers and a byte, it would still have to be padded so that the following instance will be aligned on what is required for a pointer. So, size of 3 pointers.

Which means you’re gaining nothing… but actually paying for the extra work of accessing a byte on systems that have slower byte access than word access.

I suppose there is a possible situation where both I and O are zero-sized or sub-int-sized, which means that it would make for an overall size savings if decodexx_error were sub-int-sized… but that seems wildly unlikely.

Bottom line, it isn’t worth it. And, worse, there is the possibility that if this enumeration expands to more than ~127 values, there could be problems. Sure, that’s not likely… but why create the risk for no other benefit?

Also, there is a standard protocol for error enumerations, as part of the <system_error> library. Error enumerations should have a default construction that represents “no error”. That way you can do if (error == decodexx_error{}) { /* no error */ }. Even if you don’t use <system_error>, this is a useful pattern.

template <typename I, typename O>
struct decodexx_error_result {
    std::ranges::in_out_result<I, O> in_out_result;
    decodexx_error error;
};

Is it really worth it to embed a std::ranges::in_out_result<I, O>, rather than simply doing [[no_unique_address]] I in; [[no_unique_address]] O out;? I mean, if someone wants the input iterator from the type, is e.in_out_result.in more clear than e.in?

Also, this makes it much clunkier to use. If I really want to inspect an error situation, one of the nicest tools would be to de-structure the error result with auto [i, o, err] = error;. How does it improve the ergonomics to force me to do: auto [x, err] = error; auto [i, o] = x;?

(Also, technically, if in_out_result<I, O> were zero-sized, then doing the above will necessitate a wasted byte. You could fix that by adding [[no_unique_address]] though.)

template <identifier id>
struct alphabet {};

template <identifier id>
inline constexpr auto alphabet_v = alphabet<id>::value;

template <>
struct alphabet<identifier::base16> {
    static constexpr auto value = std::string_view{"0123456789ABCDEF"};
};

So, the standard library uses this pattern— struct meow { static constexpr T value = ???; }; inline constexpr auto meow_v = foo::value; —for historical reasons: basically the meow stuff was specified, then later variable templates and inline variables were specified and the meow_v stuff was added. If the standard could start from scratch today, or didn’t have to worry about backward-compatibility, they wouldn’t do it this way. They would just do inline constexpr auto meow = ???;

Indeed, if you look at the source code for at least some modern standard libraries, they don’t use the pattern you’re using. They define the variable constant without the class. For example, libstdc++, ignoring the path that uses intrinsics, does this:

  /// is_same
  template<typename _Tp, typename _Up>
    struct is_same
    : public false_type
    { };

  template<typename _Tp>
    struct is_same<_Tp, _Tp>
    : public true_type
    { };

// ... [snip] ...


template <typename _Tp, typename _Up>
  inline constexpr bool is_same_v = false;
template <typename _Tp>
  inline constexpr bool is_same_v<_Tp, _Tp> = true;

Why? Because true is massively more efficient than instantiating multiple types—that is, is_same<X, Y>, and true_type or false_type for the result—and then querying dependent types or values.

You don’t need alphabet. After defining your alphabets, you never use it again; you always use alphabet_v (or alphabet_size or alphabet_properties, both of which are based on alphabet_v transitively). So, ditch the struct, and ditch the _v:

template<identifer>
inline constexpr auto alphabet = std::enable_if_t<always_false<id>>{}; // no default value

template<>
inline constexpr auto alphabet<identifier::base16> = std::string_view{"0123456789ABCDEF"};

The same idea applies here:

template <identifier id>
struct shift_register {};

template <identifier id>
using shift_register_t = shift_register<id>::type;

template <>
struct shift_register<identifier::base16> {
    using type = unsigned char;
};

This can just be:

template<identifier id>
using shift_register = void; // or some type-less dependent type

template <>
using shift_register<identifier::base16> = unsigned char;

However…:

template <identifier id>
struct shift_register {};

template <identifier id>
using shift_register_t = shift_register<id>::type;

template <>
struct shift_register<identifier::base16> {
    using type = unsigned char;
};

template <identifier id>
    requires(id == identifier::base32 or id == identifier::base32hex)
struct shift_register<id> {
    using type = unsigned long long;
};

template <>
struct shift_register<identifier::base64> {
    using type = unsigned long;
};

So what all the above code is trying to do is select an unsigned integer type that can hold enough octets for an entire chunk. For base16, a chunk is a single octet; for base32, it’s five octets; and for base64 it’s three.

So, first thing: I think it would be wise to explicitly check the assumption that byte ≡ octet. All that needs is a static_cast that checks std::numeric_limits<unsigned char>::digits == 8.

Now, once you’ve determined the number of bits (or octets) in an unencoded chunk—which you can determine from the size of the alphabet—you can automatically determine the best type to use:

template<identifier id>
using shift_register = std::conditional_t<
    alphabet_size<id> <= 8,
    std::uint_fast8_t,
    std::conditional_t<
        alphabet_size<id> <= 16,
        std::uint_fast16_t,
        std::conditional_t<
            alphabet_size<id> <= 32,
            std::uint_fast32_t,
            std::conditional_t<
                alphabet_size<id> <= 64,
                std::uint_fast64_t,
                void // no size big enough
            >
        >
    >
>;

So you don’t really need shift_register to be part of the public interface.

What might be useful, though, is a uint_fast_t<N> type based on the above:

template<std::size_t N>
requires (N != 0uz) and (N <= 64uz)
using uint_fast_t = std::conditional_t<
    N <= 8uz,
    std::uint_fast8_t,
    std::conditional_t<
        N <= 16uz,
        std::uint_fast16_t,
        std::conditional_t<
            N <= 32uz,
            std::uint_fast32_t,
            std::uint_fast64_t
        >
    >
>;

And then shift_register<id> is just template<identifier id> using shift_register = uint_fast_t<alphabet_size<id>>;.

Using the fixed-width types is the right move here in any case. Yes, if you need 32 bits, the portable built-in type choice is unsigned long… but that could be 64 bits (and is, on my machine). If you need 64 bits, the portable choice is unsigned long long, but this could be 64 bits, 128 bits, or a billion bits. std::uint_fast32_t is guaranteed to be the fastest type that holds at least 32 bits, and the same for std::uint_fast64_t for 64 bits. This is what you want.

On to encode:

                   return i < (props::block_num_chars -
                               (props::octet_width *
                                    (props::block_num_octets - j - 1) +
                                props::width - 1) /
                                   props::width)
                              ? basexx::padding
                              : alphabet_v<id>.at(c & (alphabet_size<id> - 1));

It’s good to use clearly named values rather than magic numbers or short identifiers like i or x, but when your calculation starts flying off the side of the screen multiple times, maybe it’s time to break things up a bit and simplify.

On to max_size:

    template <typename R>
        requires(std::ranges::sized_range<R> or std::ranges::forward_range<R>)
    // NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward)
    constexpr auto operator()(R &&r) const
        -> std::expected<size_t, decodexx_error> {
        if constexpr (std::ranges::sized_range<R>) {
            return input_size_to_max_size(std::ranges::size(r));
        } else {
            return input_size_to_max_size(std::ranges::distance(r));
        }
    }

I’m not sure why you are not using std::forward() here; it would be the correct thing to do.

And that if constexpr is unnecessary. std::ranges::distance() already defers to std::ranges::size() if it’s a sized_range.

Also, as a style matter, in C++, we keep the type modifiers with the type. In other words:

  • R &&r: this is C-style.
  • R&& r: this is C++-style.

The reason why is because the && is a property of the type, R, not the variable r.

On to try_decode.

I don’t see why try_decode is a class at all. Every time you use it, you’re just going to be doing try_decode<id>{}(...). Why is this better than try_decode<id>(...)?

        auto make_error_result =
            [first = std::move(first),
             result = std::move(result)](decodexx_error error) mutable {
                return std::unexpected{decodexx_error_result<I, O>{
                    .in_out_result = {std::move(first), std::move(result)},
                    .error = error,
                }};
            };

There are a couple of bugs here, probably based on a misunderstanding of how lamdba captures work. The capturing is not done when the lambda is called. It is done when the lambda is created. In other words, no matter where make_error_result gets called, the value of first will be the value it has right here… which is probably not where the error occurred.

That also means that doing move(first) basically destroys first, in theory. In practice, since you only test with iterators that are probably just basically pointers, a move is no different from a copy, so you won’t notice any problems (other than that they won’t point to where the error is). But, in theory, first is no good after this, meaning the very next line while (first != last) { is probably UB. Same goes for result.

But honestly, moving iterators is probably not worth it. I mean, it’s not wrong, but iterators are supposed to be simple, small, and easily copyable. I’ve honestly never written an iterator that has optimized move ops.

Speaking of iterators, your structure is basically:

constexpr auto operator()(I first, S last, O result, Proj proj = {}) const;

constexpr auto operator()(R&& r, O result, Proj proj = {}) const
{
    return (*this)(std::ranges::begin(r), std::ranges::end(r), std::move(result), std::move(proj));
}

Before C++20, this was the way to go. But since C++20, things have changed. Ranges are now a better base than iterators. So the modern way to structure this code is:

constexpr auto operator()(R&& r, O result, Proj proj = {}) const;

constexpr auto operator()(I first, S last, O result, Proj proj = {}) const
{
    return (*this)(std::ranges::subrange{first, last}, std::move(result), std::move(proj));
}

Even if the first thing you do in the range version is auto first = std::ranges::begin(r); auto last = std::ranges::end(r);, this is probably the better way to go these days. If you do need the power of ranges, you have the option, whereas if you defer to the iterator version, you don’t.


template <identifier id, template <typename> typename C>
struct try_decode_to {
    template <std::ranges::input_range R, typename Proj = std::identity>
        requires std::convertible_to<
            std::indirect_result_t<Proj, std::ranges::iterator_t<R>>, char>
    constexpr auto operator()(R &&r, Proj proj = {}) const
        -> std::expected<C<std::byte>, decodexx_error>;
};

template <identifier id>
struct try_decode_to<id, std::vector> {

Whether you’ve intended to or not, you’ve hard-coded try_decode_to so it only works with std::vector. Not even std::pmr::vector, literally only std::vector.

Re-inventing std::ranges:to() is going to be… a lot. It’s not hard, but to get the full effect, with all the optimizations (like auto-reserving) just takes a lot of code. It’s really not worth it.

The big problem here is that you’re fighting against one of the core short-comings of ranges: the lack of any real error handling. Consider how something like try_decode_to might work, even conceptually. First it would have to create a view of the decoded data, and the output range (a vector, presumably) using that view (like vector{from_range, view}). If the decoding could not fail, this would be fine. But what happens if the decoding fails at any point? How do you get “out” of constructing the output range? The only way is to throw an exception. But that immediately kills the output range, so you can’t even get the partially decoded data. Indeed, your current implementations all lose both the partially-processed input and the partially-decoded output. We get told there is an error, but… that’s it.

The way to do it might be to create a decoding view that records any error state, and use it (very roughly) like so:

template<
    identifier Id,
    template<typename...> typename Out,
    typename R,
    typename... Args
>
constexpr auto try_decode_to(R&& r, Args&&... args)
{
    auto decoded = std::forward<R>(r) | try_decode<Id>;

    auto out = decoded | std::ranges::to<Out>(std::forward<Args>(args)...);

    if (decoded.has_error())
    {
        auto error_result = decoded.error();
        return std::unexpected{try_decode_error{error_result, std::move(decoded), std::move(out)}};
    }

    return out;
}

Basically, the idea is to not just directly pipe the decoded view into the output, but rather to capture the view, pipe an lvalue reference of it into the output, and then check the view to see if it actually succeeded. If it failed, you can report the partially-read input (which is either referenced or stored in the decoded view), the partially-written output, and the error code.

But, honestly, this is not trivial. Not complex, just… a lot. Making that work will be a lot of work. If you really want to go this route and use ranges for decoding—and I don’t recommend it—then the way to go might be to make a decoder view, similar to the encoder view I mentioned above. When used “normally” as a view, it just produces a view of as much valid output as it can, stopping as soon as there is an error. It has to; ranges doesn’t handle errors. It can no longer be a sized range either (if you want that, you might need a second view that assumes errors never happen). But it can keep track of whether an error occurred.

Of course, that means you may end up with partially read input, and you won’t know… which, not great. Look, frankly, this is just a bad idea. There is no way to make it “good”. No matter what you do, you will end up with problems. Ranges does not handle errors. That is the ultimate truth that cannot be avoided. You can struggle against that truth as much as you want, but you will always end up with something that doesn’t really work all that well.

That’s why I recommended an algorithms-like interface:

// Given some input sequence/range/view called `input`:
auto output = std::vector<char>{}; // or any other output container/whatever
if constexpr (std::sized_range<decltype(input)>)
    output.reserve(estimate_decocded_size(input));
if (auto result = decode(input, std::back_inserter(output)); result)
{
    // no error, all is good
}
else
{
    // result gives error code
    // input is at the point where an error occurred
    // output contains everything successfuly decoded so far
}

It’s more effort to use, yes, but there’s no way around that if you actually want to deal with potential errors.

You could have operations that handle constructing the output automatically, sure, using std::ranges::to() internally something like what I showed above. But that’s a metric fuck-ton of work. It would be nice, but in my opinion, not nearly nice enough to be worth the effort.

The rest is mostly test code, which I won’t be reviewing.

Look, it may not sound like it, but I really like std::ranges. But there is a very good reason it hasn’t taken the C++ world by storm, and why people aren’t screaming its praises from the mountaintops. std::ranges is great for business logic and application code. It is not so great for library code. Even making a simple transformational view like encode… nothing is as simple as you’d think. Yes, you can very easily slap together things that “work”… but making things work with maximal efficiency? Almost never easy with ranges. And while application code can be less than stellar, library code needs to be maximally efficient, or the library is not worth using.

And when error-handling is involved… no. Just no. Don’t even touch ranges, even peripherally. If nothing can fail, ranges works beautifully. But if any step in a sequence can fail… forget about it. There is simply no way to avoid breaking up ranges chains, and once you do that, there is no benefit to ranges at all.

My advice:

  • Forget using standard ranges to build an encoder view. Make one from scratch.
  • Forget ranges for decoding altogether. Forget even *_to() functions. Just use an algorthims-like interface.
  • If you must make a decoder view, just accept that it won’t be very safe to use if there are errors. You can keep track of whether any errors occurred in the view itself—and you should—but you can’t make sure the user actually checks.

I know that’s probably not what you wanted to hear, but… yeah. I like ranges, but… they’re not great.

\$\endgroup\$
6
  • \$\begingroup\$ Of course, lambda capture happens during lambda creation. That bug is my unfortunate late attempt at sharing the error creation code. This does not seem to be possible. Thanks for spotting it. make_error_result should just be removed. \$\endgroup\$ Commented Oct 21, 2024 at 10:23
  • \$\begingroup\$ The encode advice makes sense. I guess this example shows that combining existing ranges adaptors for new views will most likely be suboptimal, and that implementing performant new views will always be a lot of work (compared to say transforming a std::vector into a std::string). \$\endgroup\$ Commented Oct 21, 2024 at 10:40
  • \$\begingroup\$ On the decoding side I'm a little more puzzled by your conclusion. You say "If you must make a decoder view" but there is no decoder view in the reviewed code. And with my current implementation you can already do try_decode<base>{}(input, std::back_inserter(output)), which as far as I know is the equivalent of your decode(input, std::back_inserter(output)). Users of my implementation do not have to use the *_to functions if they do not want to. \$\endgroup\$ Commented Oct 21, 2024 at 10:40
  • \$\begingroup\$ For the record, on the encoding side: performance, as usual, should be measured before drawing any final conclusion. And the alternative chunk + transform + join, at least one variant of it, was reviewed (by you), earlier. As you then pointed out, it failed to preserve several range properties. Hence the switch to cartesian_product. \$\endgroup\$ Commented Oct 21, 2024 at 12:51
  • \$\begingroup\$ “… there is no decoder view in the reviewed code” The *decode_to() functions are what I was referring to. To implement those, you have 2 options: 1) re-implement ranges::to() in an only partially functional way, so it works for types you can get away with not having a view to initialize the output with, like you did with std::vector (that works because vector has a way to add elements one at a time; for types that have to be initialized with a range (or iterator pair), you’d need a decoder view); or 2) make a decoder view and use ranges::to() proper. \$\endgroup\$ Commented Oct 22, 2024 at 12:36
2
\$\begingroup\$

When using my own implementation, I realized that the try_decode implementation for ranges was not strictly correct. When std::expected is not involved, the conversion from the iterator function result to the range function result is implicit. But with the std::expected wrapping, this is no longer the case, and the compiler needs some help:

struct try_decode {
            -> std::expected<std::ranges::in_out_result<std::ranges::borrowed_iterator_t<R>, O>,
                             decodexx_error_result<std::ranges::borrowed_iterator_t<R>, O>>
        {
-               return (*this)(std::ranges::begin(r), std::ranges::end(r), std::move(result), std::move(proj));
+               return (*this)(std::ranges::begin(r), std::ranges::end(r), std::move(result), std::move(proj))
+                   .transform([](auto &&expected) {
+                           return std::ranges::in_out_result<std::ranges::borrowed_iterator_t<R>, O>{
+                               .in{std::move(expected.in)},
+                               .out{std::move(expected.out)},
+                           };
+                   })
+                   .transform_error([](auto &&error) {
+                           return decodexx_error_result<std::ranges::borrowed_iterator_t<R>, O>{
+                               .in_out_result{
+                                   .in{std::move(error.in_out_result.in)},
+                                   .out{std::move(error.in_out_result.out)},
+                               },
+                               .error{error.error},
+                           };
+                   });
        }
 };

In fact, most often, this is not needed, but it is for the corner case where we pass an rvalue range argument, and the function has to return a wrapped std::ranges::dangling instead of a wrapped iterator.

With the change above, we can simplify one of the test cases:

 void test_encode64_decode64()
 {
-       // Note: the try_decode functions return updated input and output iterators when they are successful. If the
-       // passed range to decode is an automatic variable, the returned iterators would be dangling. In such a case,
-       // even if we use the decode functions, which throw away those iterators, the decoded expression is not a
-       // constant expression. In order to avoid that problem, we need to ensure that the encoded range still is in
-       // scope when the decoding function returns. Hence the somewhat verbose code below.
        constexpr auto man = "\x{4D}\x{61}\x{6E}"sv;
-       constexpr auto encoded = man | to_bytes | encode64;
-       static_assert(equal(decode64_to_vector(encoded), man | to_bytes));
+       static_assert(equal(decode64_to_vector(man | to_bytes | encode64), man | to_bytes));
 }

Without the change above, that static_assert complains about the expression not being a constant expression, but that is in fact just because the compiler fails to convert the return type to a wrapped std::ranges::dangling.

Note: of course we do not dereference that dangling. If we tried that, we would get a compilation error, something we in that case would want.

And while I was at it, I made another smaller change, which was not strictly necessary, but which makes the code a little more range-idiomatic (more monadic):

struct try_decode_to<id, std::vector> {
 
                std::vector<std::byte> v(*size);
 
-               const auto result = try_decode<id>{}(std::forward<R>(r), v.begin(), std::move(proj));
-
-               if (result.has_value()) {
-                       v.resize(result->out - v.begin());
-               }
-
-               return result.transform([v = std::move(v)](auto &&) mutable { return std::move(v); })
+               return try_decode<id>{}(std::forward<R>(r), v.begin(), std::move(proj))
+                   .transform([v = std::move(v)](const auto &result) mutable {
+                           v.resize(result.out - v.begin());
+                           return std::move(v);
+                   })
                    .transform_error([](const auto &error) { return error.error; });
        }
 };

I have also updated the godbolt version.

\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.