Повседневный C++: изолируем API в стиле C

Habrahabr 5

Мы все ценим C++ за лёгкую интеграцию с кодом на C. И всё же, это два разных языка.

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

Не смешивайте C и бизнес-логику на C++

Не так давно я случайно заметил в своём любимом компоненте новую вставку. Мой код стал жертвой Tester-Driven Development.

Согласно википедии, Tester-driven development — это антиметодология разработки, при которой требования определяются багрепортами или отзывами тестировщиков, а программисты лишь лечат симптомы, но не решают настоящие проблемы

Я сократил код и перевёл его на С++17. Внимательно посмотрите и подумайте, не осталось ли чего лишнего в рамках бизнес-логики:

bool DocumentLoader::MakeDocumentWorkdirCopy()
{
    std::error_code errorCode;
    if (!std::filesystem::exists(m_filepath, errorCode) || errorCode)
    {
        throw DocumentLoadError(DocumentLoadError::NotFound(), m_filepath, errorCode.message());
    }
    else
    {
        // Lock document
        HANDLE fileLock = CreateFileW(m_filepath.c_str(),
                GENERIC_READ,
                0, // Exclusive access
                nullptr, // security attributes
                OPEN_EXISTING,
                FILE_ATTRIBUTE_NORMAL,
                nullptr //template file
            );
        if (!fileLock)
        {
            CloseHandle(fileLock);
            throw DocumentLoadError(DocumentLoadError::IsLocked(), m_filepath, "cannot lock file");
        }
        CloseHandle(fileLock);
    }

    std::filesystem::copy_file(m_filepath, m_documentCopyPath);
}

Давайте опишем словесно, что делает функция:

  • если файл не существует, выбрасывается исключение с кодом NotFound и путём к файлу
  • иначе открыть файл с заданным путём на чтение, с эксклюзивными правами доступа, без аттрибутов безопасности, по возможности открыть существующий, при создании нового файла поставить ему обычные атрибуты файла, не использовать файл-шаблон
  • и если предыдущая операция не удалась, закрываем файл и бросаем исключение с кодом IsLocked
  • иначе закрываем файл и копируем его

Вам не кажется, что кое-что тут выпадает из уровня абстракции функции?

Ненужная иллюстрация

Не смешивайте слои абстракции, код с разным уровнем детализации логики должен быть разделён границами функции, класса или библиотеки. Не смешивайте C и C++, это разные языки.

На мой взгляд, функция должна выглядеть так:

bool DocumentLoader::MakeDocumentWorkdirCopy()
{
    boost::system::error_code errorCode;
    if (!boost::filesystem::exists(m_filepath, errorCode) || errorCode)
    {
        throw DocumentLoadError(DocumentLoadError::NotFound(), m_filepath,      errorCode.message());
    }
    else if (!utils::ipc::MakeFileLock(m_filepath))
    {
        throw DocumentLoadError(DocumentLoadError::IsLocked(), m_filepath, "cannot lock file");
    }

    fs::copy_file(m_filepath, m_documentCopyPath);
}

Почему C и C++ разные?

Начнём с того, что они родились в разное время и у них разные ключевые идеи:

  • лозунг C — "Доверяй программисту", хотя многим современным программистам уже нельзя доверять
  • лозунг C++ — "Не плати за то, что не используешь", хотя вообще-то дорого заплатить можно и просто за неоптимальное использование

В C++ ошибки обрабатываются с помощью исключений. Как они обрабатываются в C? Кто вспомнил про коды возврата, тот неправ: стандартная для языка C функция fopen не возвращает информации об ошибке в кодах возврата. Далее, out-параметры в C передаются по указателю, а в C++ программиста за такое могут и отругать. Далее, в C++ есть идиома RAII для управления ресурсами.

Мы не будем перечислять остальные отличия. Просто примем как факт, что мы, C++ программисты, пишем на C++ и вынуждены использовать API в стиле C ради:

  • OpenGL, Vulkan, cairo и других графических API
  • CURL и других сетевых библиотек
  • winapi, freetype и других библиотек системного уровня

Но использовать не значит "пихать во все места"!

Как открыть файл

Если вы используете ifstream, то с обработкой ошибок попытка открыть файл выглядит так:

int main()
{
    try
    {
        std::ifstream in;
        in.exceptions(std::ios::failbit);
        in.open("C:/path-that-definitely-not-exist");
    }
    catch (const std::exception& ex)
    {
        std::cout << ex.what() << std::endl;
    }
    try
    {
        std::ifstream in;
        in.exceptions(std::ios::failbit);
        in.open("C:/");
    }
    catch (const std::exception& ex)
    {
        std::cout << ex.what() << std::endl;
    }
}

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

Скриншот ошибки fstream

Типичный код, использующий API в стиле C, ведёт себя хуже: он даже не даёт гарантии безопасности исключений. В примере ниже при выбросе исключения из вставки // .. остальной код файл никогда не будет закрыт.

// Держи это, если ты вендовоз
#if defined(_MSC_VER)
#define _CRT_SECURE_NO_WARNINGS
#endif

int main()
{
    try
    {
        FILE *in = ::fopen("C:/path-that-definitely-not-exist", "r");
        if (!in)
        {
            throw std::runtime_error("open failed");
        }
        // ..остальной код..
        fclose(in);
    }
    catch (const std::exception& ex)
    {
        std::cout << ex.what() << std::endl;
    }
}

А теперь мы возьмём этот код и покажем, на что способен C++17, даже если перед нами — API в стиле C.

А почему бы не сделать как советует ООП?

Валяйте, попробуйте. У вас получится ещё один iostream, в котором нельзя просто взять и узнать, сколько байт вам удалось прочитать из файла, потому что сигнатура read выглядит примерно так:

basic_istream& read(char_type* s, std::streamsize count);

А если вы всё же хотите воспользоваться iostream, будьте добры вызвать ещё и tellg:

// Функция читает не более чем count байт из файла, путь к которому задан в filepath
std::string GetFirstFileBytes(const std::filesystem::path& filepath, size_t count)
{
    assert(count != 0);

    // Бросаем исключение, если открыть файл нельзя
    std::ifstream stream;
    stream.exceptions(std::ifstream::failbit);

    // Маленький фокус: C++17 позволяет конструировать ifstream
    //  не только из string, но и из wstring
    stream.open(filepath.native(), std::ios::binary);

    std::string result(count, '\0');
    // читаем не более count байт из файла
    stream.read(&result[0], count);
    // обрезаем строку, если считано меньше, чем ожидалось.
    result = result.substr(0, static_cast<size_t>(stream.tellg()));

    return result;
}

Одна и та же задача в C++ решается двумя вызовами, а в C — одним вызовом fread! Среди множества библиотек, предлагающих C++ wrapper for X, большинство создаёт подобные ограничения или заставляет вас писать неоптимальный код. Я покажу иной подход: процедурный стиль в C++17.

Шаг первый: RAII

Джуниоры не всегда знают, как создавать свои RAII для управления ресурсами. Но мы-то знаем:

namespace detail
{
// Функтор, удаляющий ресурс файла
struct FileDeleter
{
    void operator()(FILE* ptr)
    {
        fclose(ptr);
    }
};
}

// Создаём FileUniquePtr - синоним специализации unique_ptr, вызывающей fclose
using FileUniquePtr = std::unique_ptr<FILE, detail::FileDeleter>;

Такая возможность позволяет завернуть функцию ::fopen в функцию fopen2:

// Держи это, если ты вендовоз
#if defined(_MSC_VER)
#define _CRT_SECURE_NO_WARNINGS
#endif

// Функция открывает файл, пути в Unicode открываются только в UNIX-системах.
FileUniquePtr fopen2(const char* filepath, const char* mode)
{
    assert(filepath);
    assert(mode);
    FILE *file = ::fopen(filepath, mode);
    if (!file)
    {
        throw std::runtime_error("file opening failed");
    }
    return FileUniquePtr(file);
}

У такой функции ещё есть три недостатка:

  • она принимает параметры по указателям
  • исключение не содержит никаких подробностей
  • не обрабатываются Unicode-пути на Windows

Если вызвать функцию для несуществующего пути и для пути к каталогу, получим следующие тексты исключений:

Скриншот ошибки

Шаг второй: собираем информацию об ошибке

Во-первых мы должны узнать у ОС причину ошибки, во-вторых мы должны указать, по какому пути она возникла, чтобы не потерять контекст ошибки в процессе полёта по стеку вызовов.

И тут надо признать: не только джуниоры, но и многие мидлы и синьоры не в курсе, как правильно работать с errno и насколько это потокобезопасно. Мы напишем так:

// Держи это, если ты вендовоз
#if defined(_MSC_VER)
#define _CRT_SECURE_NO_WARNINGS
#endif

// Функция открывает файл, пути в Unicode открываются только в UNIX-системах.
FileUniquePtr fopen3(const char* filepath, const char mode)
{
    using namespace std::literals; // для литералов ""s.

    assert(filepath);
    assert(mode);
    FILE *file = ::fopen(filepath, mode);
    if (!file)
    {
        const char* reason = strerror(errno);
        throw std::runtime_error("opening '"s + filepath + "' failed: "s + reason);
    }
    return FileUniquePtr(file);
}

Если вызвать функцию для несуществующего пути и для пути к каталогу, получим более точные тексты исключений:

Скриншот подробной ошибки

Шаг третий: экспериментируем с filesystem

C++17 принёс множество маленьких улучшений, и одно из них — модуль std::filesystem. Он лучше, чем boost::filesystem:

  • в нём решена проблема 2038 года, а в boost::filesystem не решена
  • в нём есть однозначный способ получить UTF-8 путь, а ведь ряд библиотек (например, SDL2) требуют именно UTF-8 пути
  • реализация boost::filesystem содержит опасные игры с разыменованием указателей, в ней много Undefined Behavior

Для нашего случая filesystem принёс универсальный, не чувствительный к кодировкам класс path. Это позволяет прозрачно обработать Unicode пути на Windows:

// В VS2017 модуль filesystem пока ещё в experimental
#include <cerrno>
#include <cstring>
#include <experimental/filesystem>
#include <fstream>
#include <memory>
#include <string>

namespace fs = std::experimental::filesystem;

FileUniquePtr fopen4(const fs::path& filepath, const char* mode)
{
    using namespace std::literals;

    assert(mode);
#if defined(_WIN32)
    fs::path convertedMode = mode;
    FILE *file = ::_wfopen(filepath.c_str(), convertedMode.c_str());
#else
    FILE *file = ::fopen(filepath.c_str(), mode);
#endif
    if (!file)
    {
        const char* reason = strerror(errno);
        throw std::runtime_error("opening '"s + filepath.u8string() + "' failed: "s + reason);
    }
    return FileUniquePtr(file);
}

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

Заглядывая в будущее: мир без препроцессора

Сейчас я покажу вам код, который в июне 2017 года, скорее всего, не скомпилирует ни один компилятор. Во всяком случае, в VS2017 constexpr if ещё не реализован, а GCC 8 почему-то компилирует ветку if и выдаёт следующую ошибку:

Скриншот ошибки компиляции

Да-да, речь пойдёт о constexpr if из C++17, который предлагает новый способ условной компиляции исходников.

FileUniquePtr fopen5(const fs::path& filepath, const char* mode)
{
    using namespace std::literals;

    assert(mode);
    FILE *file = nullptr;
    // Если тип path::value_type - это тип wchar_t, используем wide-функции
    // На Windows система хочет видеть пути в UTF-16, и условие истинно.
    //  примечание: wchar_t пригоден для UTF-16 только на Windows.
    if constexpr (std::is_same_v<fs::path::value_type, wchar_t>)
    {
        fs::path convertedMode = mode;
        file = _wfopen(filepath.c_str(), convertedMode.c_str());
    }
    // Иначе у нас система, где пути в UTF-8 или вообще нет Unicode
    else
    {
        file = fopen(filepath.c_str(), mode);
    }
    if (!file)
    {
        const char* reason = strerror(errno);
        throw std::runtime_error("opening '"s + filepath.u8string() + "' failed: "s + reason);
    }
    return FileUniquePtr(file);
}

Это потрясающая возможность! Если в язык C++ добавят модули и ещё несколько возможностей, то мы сможем забыть препроцессор из языка C как страшный сон и писать новый код без него. Кроме того, с модулями компиляция (без компоновки) станет намного быстрее, а ведущие IDE будут с меньшей задержкой реагировать на автодополнение.

Плюсы процедурного стиля

Хотя в индустрии правит ООП, а в академическом коде — функциональный подход, фанатам процедурного стиля пока ещё есть чему радоваться.

  • процедурный стиль легче понять, он проще для джуниоров и на нём написано большинство коротких примеров в сети
  • вы можете завернуть функции C, практически не меняя семантику: наша функция fopen4 по-прежнему использует флаги, mode и другие фокусы в стиле C, но надёжно управляет ресурсами, собирает всю информацию об ошибке и аккуратно принимает параметры
  • документация функции fopen всё-ещё актуальна для нашей обёртки, это сильно облегчает поиск, понимание и переиспользование другими программистами

Я рекомендую все функции стандартной библиотеки C, WinAPI, CURL или OpenGL завернуть в подобном процедурном стиле.

Подведём итоги

На C++ Russia 2016 и C++ Russia 2017 замечательный докладчик Михаил Матросов показывал всем желающим, почему не нужно использовать циклы и как жить без них:

Насколько известно, вдохновением для Михаила служил доклад 2013 года "C++ Seasoning" за авторством Sean Parent. В докладе было выделено три правила:

  • не пишите низкоуровневые циклы for и while
    • используйте алгоритмы и другие средства из STL/Boost
    • если готовые средства не подходят, заверните цикл в отдельную функцию
  • не работайте с new/delete напрямую
  • не используйте низкоуровневые примитивы синхронизации, такие как mutex и thread

Я бы добавил ещё одно, четвёртное правило повседневного C++ кода. Не пишите на языке Си-Си-Плюс-Плюс. Не смешивайте бизнес-логику и язык C.

  • Заворачивайте язык C как минимум в один слой изоляции.
  • Если речь об асинхронном коде, заворачивайте в два слоя: первый изолирует C, второй — прячем примитивы синхронизации и шедулинг задач на потоках

Причины прекрасно показаны в этой статье. Сформулируем их так:

Только настоящий герой может написать абсолютно надёжный код на C/C++. Если на работе вам каждый день нужен герой — у вас проблема.