Изучение C ++ STL для определения статуса возврата функции

Sphero выпустила описанного выше робота RVR в 2019 году. Перед его выпуском я работал над созданием робота с аналогичными возможностями. Я отказался от своих усилий по созданию RVR. Это привело к экспедиции в обратный инжиниринг их протокола связи последовательного порта.

По мере продвижения работы стало ясно, что данные с RVR могут быть, а могут и не быть доступны. (Я объясню позже.) Код, запрашивающий данные через вызов функции, должен был знать, есть ли данные или нет. Текущие передовые практики в C ++ советуют, чтобы все выходные данные функции осуществлялись через возврат функции, а не через выходные параметры. Я хотел создать класс Result, который бы возвращал данные или их отсутствие. СозданиеResult потребовало погружения в укромные уголки C ++ 17 и C ++ 20. Исследование привело к трем возможностям: std :: any, std :: optional и std :: variant. В этом и заключается результат (извините!) Этой работы.

Почему результат класса?

Sphero предоставляет Python SDK, но я работаю на C ++. Я планирую использовать одноплатный компьютер (SBC) на базе Intel Up board, работающий под управлением Linux.

В документации представлены подробные сведения о структуре и значениях пакетов сообщений последовательного канала. Он описывает команды как вызовы функций. Что-то еще, например детали формата команд и ответов, нужно было почерпнуть из кода Python.

Протокол считает SBC хостом, а RVR - целью. Хост отправляет сообщение цели, в то время как RVR отвечает, когда может. Когда хост отправляет запрос, он не может ожидать немедленного ответа. Кроме того, если хост отправляет несколько запросов, ответы могут не возвращаться по порядку. Например, если хост отправляет A, B и C, ответы могут быть B, C и A. Кроме того, потоковый режим периодически отправляет данные, то есть RVR может повторять ответ B каждые 50 миллисекунд.

Рассмотрим более конкретный пример. Один из запросов - передать в потоке окружающий свет, воспринимаемый RVR. (Сверху находится датчик освещенности.) Программное обеспечение хоста должно сохранять значение внешней освещенности при его получении. После запроса RVR продолжает периодически сообщать это значение, скажем, каждые 50 мс. Код приложения, запрашивающий сохраненные данные перед приемом, требует ответа Нет данных. В конце концов данные становятся доступными.

Чтобы проиллюстрировать это, вот фрагмент кода более высокого уровня, показывающий желаемое использование дизайна:

rvr::SensorsStream& sen_stream...
Result<float> ambient { sen_stream.ambient()};
if (ambient.valid()) {...}

Этот код создает экземпляр класса SensorStream и вызывает sen_stream.ambient() для получения сохраненного значения внешнего освещения. Тип Result<float> будет содержать данные или указание Нет данных. Последняя строка проверяет, содержит ли Result<float> данные, то есть данные допустимы.

На уровне sen_stream.ambient() код выглядит так:

Result<float> rvr::SensorStream::ambient() {
    std::string msg {...request response message from a map...};
    Result<float> res;
    if (msg) {
        // ...decode msg
        res = ...decoded msg
    }
    return res;
}

Это шаблон для всех запросов данных. Необработанное сообщение хранится в std::map с ключом, основанным на кодах команд и другой информации в ответе. Если сообщение на карте пустое, возвращается пустая строка. Определение res создает Result без данных. Если сообщение содержит данные, оно декодируется, и данные присваиваются res.

Необходимость проверки после вызова функции является недостатком и проблемой для большинства языков программирования. Как и выше, это неплохо для одного вызова, но просмотр вызовов для 20 значений датчиков и их проверок делает код почти нечитаемым. Возможно, я найду решение позже, но, вероятно, нет. Все, что я могу сделать, это создать Result<float> и все другие возможные возвращаемые значения. Как и я, мы исследуем три интересные возможности C ++.

Три кандидата: std :: any, std :: optional, std :: variant

Могут быть и другие возможности, но мы рассмотрим три:

  • std :: any - может содержать значение любого типа без указания типа,
  • std :: optional ‹T› - может содержать значение типа T или не иметь значения,
  • std :: variant ‹T, S…› - может содержать любой тип из списка T, S,…

Из этих трех, очевидно, следует рассмотреть std::optional. Если данные из RVR доступны, они возвращаются; в противном случае возврат указывает на отсутствие данных.

Я начал с std::optional, но столкнулся с проблемой. Я пробовал std::variant И это сработало. Получение std :: variant работать показало мне, что было не так с моей попыткой std::optional, поэтому я вернулся к нему. Это программное обеспечение. Если с первого раза у вас ничего не получится, попробуйте что-нибудь другое. Часто это показывает, что было не так с первого раза.

Усложняющим фактором является необходимость возвращать много типов: Result<float>, Result<uint16_t>, Result<string>... Одно из возможных преимуществ std::any или std::variant - они могут обрабатывать несколько типов. Недостатком std::variant и std::optional является необходимость указывать типы. Недостатком std::any является то, что он динамически выделяет место для своего содержимого, хотя также может использовать оптимизацию коротких значений. И std::optional, и std::variant не могут, согласно спецификации C ++, использовать динамическое размещение. Тем не менее, поскольку std::any может иметь преимущество из-за гибкости спецификации типа, я исследовал его использование.

Скелет для результата

Общая реализация класса Result аналогична, независимо от используемой базовой альтернативы. Вот схема кода:

template <typename T>
struct Result : protected <<one of the alternatives>> {
    explicit constexpr Result() noexcept = default;
    constexpr Result(T&& t) noexcept: <<one of the alternatives>>{t}{ }
    constexpr bool valid() const noexcept;
    constexpr bool invalid() const noexcept;
    constexpr auto const get() const noexcept -> T;
};
using ResultInt = Result<int>;
using ResultString = Result<std::string>;

Оказывается, мы не можем избежать использования шаблонного класса с std::any, так что это лишает его большого преимущества. В методе get(), для возвращаемого значения необходим тип, иначе метод не знает, что возвращать. См. Подробности в разделе std::any ниже.

Классы STL - это базовые классы для класса Result. (Но см. Позднее изменение.) Наследование protected, чтобы разрешить Result доступ к базовым методам, но не допустить их раскрытия пользователю. В этом отношении я могу быть излишне осторожным. В частности, я хотел запретить пользователю обходить использование метода get() путем доступа к базовым методам доступа к данным. Некоторые из них выдают исключения, если данные недоступны, и я хотел предотвратить эту ситуацию.

Результаты Методы

На мгновение игнорируя конструкторы, эти три метода предоставляют рабочие детали класса. Оба valid() и invalid() сообщают, сохранено ли значение. Метод get() возвращает значение или созданную по умолчанию версию значения. Это позволяет избежать генерации исключения базовым классом, когда значение отсутствует.

Есть два подхода к получению ценности. Самый простой - использовать get() и каким-то образом разрешить значение по умолчанию. В некоторых случаях это может сработать, поэтому класс предусматривает такую ​​возможность.

Более сложный подход состоит в том, чтобы сначала проверить на valid() и использовать get() только при наличии данных. Как вы увидите, функция get() работает таким образом.

Метод invalid() предназначен для удобства, как в while(some_var.invalid()) {...}

Конструкторы

Теперь о конструкторах. Они необходимы, чтобы справиться с несколькими ситуациями, проиллюстрированными:

ResultInt func(bool const test) {
    ResultInt res;   // Result() constructor
    if (test) {
       res = 42;     // Result(T const&&) constructor
       }
    return res;
    }

В функции конструктор по умолчанию - Result()- требуется для определения res в func().. Это создает ResultInt без значения. Состояние test определяет, присвоены ли данные res. Когда test имеет значение false, данные не назначаются; когда true, данные назначаются. Присваивание использует конструктор преобразования для создания Result - фактически ResultInt - со значением. Единственный параметр конструктора преобразования - это ссылка на rvalue, которая принимает rvalue и значения.

Псевдонимы типов

Выражения using создают удобные псевдонимы для результатов разных типов. Их использование проиллюстрировано в func().

Закончив общее обсуждение, мы переходим к деталям каждой альтернативной реализации.

Result Based on std::any

Использование std::any началось как попытка избежать указания типа. К сожалению, это не работает, потому что при возврате данных из Result требуется тип. Это разработка программного обеспечения. Исследуй, экспериментируй и учись.

Вот версия std::any:

template <typename T>    // constant size of 16
struct Result final : protected std::any {
    constexpr Result(T const&& t) noexcept
        : std::any { t } {
    }
    explicit constexpr Result( ) noexcept = default;
    Result(Result const& other) = default;
    Result& operator=(Result const& other) = default;
​
    constexpr bool valid( ) const noexcept {
        return has_value( );
    }
    constexpr bool invalid( ) const noexcept {
        return !valid( );
    }
    constexpr auto const get( ) const noexcept -> T {
        return (valid( ) ? std::any_cast<T>(*this) : T( ));
    }
};

Это заполняет скелет Result, используя std::any. Есть только три детали реализации, относящиеся к std::any..

  1. Использование std::any в качестве базового класса и в конструкторе преобразования.
  2. Использование has_value() для проверки существования значения.
  3. Использование функции, не являющейся членом, для получения фактического значения.

Обратите внимание, что конструктор по умолчанию создается путем указания = default.. Так обстоит дело во всех реализациях.

В Result.get() вызов valid() определяет, есть ли данные. Если есть, он использует функцию std::any_cast<T> для получения данных. В противном случае используется созданное по умолчанию значение.

Результат на основе std :: variant

Поскольку для std::any версии Result требовалась спецификация типа, это оказалось последним из возможных решений. Это оставило std::variant как возможность вместо использования std::optional.. Вот его реализация:

template <typename T>    // size 8 for int, 40 for string
struct Result : protected std::variant<std::monostate, T> {
    explicit constexpr Result( ) noexcept = default;
    constexpr Result(T const&& t) noexcept
        : std::variant<std::monostate, T> { t } {
    }
​
    constexpr bool valid( ) const noexcept {
        return std::holds_alternative<T>(*this);
    }
    constexpr bool invalid( ) const noexcept {
        return !valid( );
    }
    constexpr auto const get( ) const noexcept -> T {
        return (valid( ) ? std::get<T>(*this) : T( ));
    }
};

std::variant аналогичен union. Это позволяет нескольким различным типам размещаться в одном пространстве памяти. Основы этой версии такие же, как и у версии std::any. Конкретные std::variant методы, используемые в этой реализации, изменились, но они эквивалентны методам во всех других альтернативах STL. Несколько отличается std::holds_alternative проверка на наличие данных. Это функция шаблона, не являющаяся членом, которая ищет тип в экземпляре std::variant.

Следует отметить, что std::monostate используется как первый тип. Этот тип не содержит данных. Его основная цель - предоставить конструируемый тип в качестве первого типа для std::variant. Требуется, чтобы первый тип в std::variant был конструируемым, чтобы разрешить создание экземпляра по умолчанию, то есть экземпляра без данных. В этом случае он работает как флаг, указывающий на отсутствие данных.

Лучшее, что я могу сказать об этой версии, так это то, что она работает. На самом деле все в порядке, но это не так хорошо, как std::optional могло бы соответствовать требованиям. Если бы я получил std::optional работу сначала, это не было бы рассмотрено, и у меня не было бы материала для статьи.

Результат на основе std :: optional

Основание Result на std::optional всегда было лучшим выбором. Несчастный случай привел к изучению альтернатив. Итак, вот версия, использующая лучший выбор: без больших сюрпризов. Это похоже на другие реализации, за исключением использования других методов для внутренних компонентов. Интересным методом в std::optional является метод преобразования operator bool как альтернатива методу has_value(). Я считаю странным или непоследовательным не предоставлять этот метод во всех этих классах. Еще один интересный метод - это value_or(), который обрабатывает тест, используемый в других реализациях.

template <typename T>    // size 8 for int, 40 for string
struct Result : protected std::optional<T> {
    explicit constexpr Result( ) noexcept = default;
​
    constexpr Result(T const&& t) noexcept
        : std::optional<T> { t } {
    }
​
    [[nodiscard]] constexpr bool valid( ) const noexcept {
        //        return bool( *this);
        return std::optional<T>::has_value( );
    }
​
    [[nodiscard]] constexpr bool invalid( ) const noexcept {
        return !valid( );
    }
​
    [[nodiscard]] constexpr auto get( ) const noexcept -> T {
        return std::optional<T>::value_or(T( ));
    }
};

Как и ожидалось, эта версия работает нормально и, будучи основана на std::optional, кажется элегантным решением. По крайней мере, до тех пор, пока при написании этой статьи не возникли разные мысли.

Другой результат с использованием std :: optional

Когда я писал эту статью, я рассматривал три вопроса:

  1. Наследование от стандартного библиотечного класса казалось некорректным, хотя все эти реализации работали нормально.
  2. Следует ли Result быть помеченным final, чтобы он не мог служить базовым классом?
  3. Возврат созданного по умолчанию значения удалил решение от пользователя класса.

Ниже представлена ​​реализация, в которой не используется наследование. Вместо этого std::optional - член класса. Добавлен get_or() метод, который возвращает значение типа по умолчанию, если данные недоступны. Метод get() вызовет исключение, если нет данных. Пользователь Result должен выполнить проверку.

template <typename T>    // size 8 for int, 40 for string
class Result {
public:
    constexpr Result(T const&& t) noexcept
        : mOptional { t } {
    }
    explicit constexpr Result( ) noexcept = default;
​
    [[nodiscard]] constexpr bool valid( ) const noexcept {
        return mOptional.has_value( );
    }
    [[nodiscard]] constexpr bool invalid( ) const noexcept {
        return !valid( );
    }
    [[nodiscard]] constexpr auto get( ) const -> T {
        return mOptional.value( );
    }
    [[nodiscard]] constexpr auto const get_or( ) const noexcept -> T {
        return mOptional.value_or(T( ));
    }
​
private:
    std::optional<T> mOptional;
};

Я все еще обсуждаю финал. Класс final, возможно, более эффективен благодаря оптимизации компилятора. Возможно, просмотр кода на CppInsights даст некоторую информацию.

Между этой версией и версией наследования нет большой разницы. Он изменился на class, поскольку необходимо, чтобы в разделе private: содержался элемент данных mOptional. Скорее всего, это версия, которую я буду использовать в библиотеке RVR, потому что ее элегантность превосходит другие std::optional версии.

Почему бы не использовать исключения?

Это хороший вопрос по спорному вопросу. Любой ответ, который я делаю, чреват опасностью. Мой ответ основан на C ++ и не может быть распространен на другие языки.

Во-первых, исключения стоят дорого. Они добавляют и код, и дополнительную обработку.

Во-вторых, это не та ситуация, когда применяются исключения. Для меня исключения - это исключения, а не статус или ожидаемые условия ошибки.

Я использую исключения в зависимости от состояния объекта. У каждого объекта есть состояние, то есть переменные в классе. Одно из правил состоит в том, что состояние объекта всегда действительно при входе в метод класса или выходе из него. Нарушение этого требования - это мое определение исключительного состояния.

Предлагаемый std :: expect

Есть предложение добавить std::expect в библиотеку C ++. Этот класс добавляет возможности помимо моего Result класса. Класс будет похож на std::variant<ReturnType, Error>, но с дополнительными возможностями. Было бы легко добавить некоторые из предложенных возможностей в Result или даже реализовать класс. Однако, как показывают мои примеры выше, мое требование - простая действительная / недействительная возможность. Поскольку я использую Result, требования могут предложить переключиться на этот предложенный класс.

Выражаю благодарность Бартеку Филипеку из C ++ Stories за то, что рассказал мне об этом предложении. Блог Бартека - отличный источник информации о C ++. У него также есть две книги, которые я очень рекомендую: C ++ 17 в деталях и C ++ Lambda Story.

Подведение итогов и запрос комментариев

Существует несколько версий Result. Это было хорошее упражнение для изучения этих трех альтернатив и современного C ++. Они разделяют способность содержать несколько значений и предоставляют аналогичные интерфейсы для извлечения данных, но std::optional доказали, что это более элегантный подход. Возможно, совместное использование процесса их развития покажет, что их собственный процесс действителен.

Вот визуальная ссылка на эти и другие специальные контейнеры.

Третья книга, заслуживающая упоминания, - C ++ 20 Райнера Гримма. Единственная проблема в том, что я не должен читать это во время написания статей. В итоге я меняю код на основе новой информации из книги. Затем мне нужно отредактировать статью.

Не могли бы вы использовать возможность комментирования, если у вас есть идеи, как это можно улучшить или сделать иначе? Поскольку я не юрист по языку C ++, не стесняйтесь предлагать, где я что-то неправильно сформулировал. Если вы нашли эту статью полезной, дайте мне несколько аплодисментов.