Мы все ценим 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 == INVALID_HANDLE_VALUE)
        {
            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++. Если на работе вам каждый день нужен герой — у вас проблема.
Доступен ли вам великолепный C++17?

Проголосовало 292 человека. Воздержалось 79 человек.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться с друзьями
-->

Комментарии (63)


  1. shevmax
    17.06.2017 21:28
    +9

    У вас ошибка в первом коде. CreateFileW возвращает дескриптор файла или INVALID_HADLE_VALUE = (HANDLE)-1. т.е. проверка на !fileLock не имеет смысла и тем более дальнейшее закрытие тоже.


    1. Door
      18.06.2017 01:26
      +1

      + логика странная (зачем открывать файл и сразу же его закрывать) + ф-я возвращала bool, судя по всему, но потом была переделка на исключения. В общем, пример неудачен да ещё и с ошибками


      1. khim
        18.06.2017 01:39

        Пример как раз удачен. Типичный результат tester-driven development — я уверен, что это всё закрывало какие-то баги в багтерекре.

        А вся статья, как бы, описывает: как стоило бы решить ту же проблему нормально…


      1. madcomaker
        18.06.2017 09:27
        +1

        Открыть и закрыть файл это самый быстрый и удобный способ проверить его существование. Все обертки внутри это самое и делают.


        1. Door
          18.06.2017 14:10

          я имел в виду то, что написал mickvav ниже


        1. ColdPhoenix
          19.06.2017 12:41

          не все, большинство делают запрос к ФС.


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


        1. Quei
          19.06.2017 15:09

          Открыть файл как раз самый медленный способ проверить его существование. Он отрабатывает быстро только после попадания файла в дисковый кеш.


        1. Dmitri-D
          19.06.2017 15:09

          нет. Нормальные оберкти внутри ничего такого не делают- файлы не открываете.
          Под windows — используют FindFirstFileW() и stat() под unix
          иначе вы расширяете проверку до «есть ли файл и можно ли его открыть текущему польвавателю»


    1. sergey_shambir
      19.06.2017 15:08

      Спасибо! Я был уж очень невнимателен, когда выделял код для публикации (позор, позор мне). В оригинале всё-таки был кастомный RAII FileHandle, который считал INVALID_HANDLE_VALUE за нулевое значение и корректно вызывал CloseHandle в остальных случаях. Я хотел убрать этот RAII чтобы не запутывать.
      Исправил в статье.


  1. mickvav
    18.06.2017 10:01
    +4

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


    1. sergey_shambir
      19.06.2017 15:10

      Я был невнимателен, когда готовил первый кусок кода к публикации. Спасибо.


  1. madcomaker
    18.06.2017 10:17
    +2

    // Держи это, если ты вендовоз
    #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;
        errno_t err;
    #if defined(_MSC_VER)
        err = ::fopen_s(&file, filepath, mode);
    #else
        file = ::fopen(filepath, mode);
        if (!file)
            err = errno;
    #endif
        if(err)
        {
            const char* reason = strerror(err);
            throw std::runtime_error("opening '"s + filepath + "' failed: "s + reason);
        }
        return FileUniquePtr(file);
    }
    


    1. sergey_shambir
      19.06.2017 15:59
      +2

      Теперь в ветке #else переменная err не инициализирована и при корректном file может содержать мусор, что превратится в фальшивую ошибку. Я не люблю функции с суффиксом "_s" как раз по этой причине: они создают видимость более безопасного решения, при этом зачастую даже провоцируют ошибки, как scanf_s. Ну и кроме того, это специфичный для компилятора код, введённый без особых причин. На использование wchar_t версии хотя бы есть причина — поддержка Unicode-путей.


      1. madcomaker
        19.06.2017 16:33
        +1

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

        Специфичный для компилятора код все равно присутствовал в виде отключения ворнингов CRT. Дико злюсь, когда вижу это в заголовках какой-нибудь библиотеки, как и #pragma warning(disable) без возврата «как было». В своем .cpp можно творить что угодно, но зачем чужому коду указывать, каким ему быть.


    1. mayorovp
      19.06.2017 16:17

      На самом деле, надо проверять не только _MSC_VER, но и __STDC_VERSION__ ...


  1. orcy
    18.06.2017 15:26

    Считается ли модным сейчас в C++ использовать исключения или коды возвраты?


    1. madcomaker
      18.06.2017 17:53

      Мода тут ни при чем. Код возврата это одно, исключение это другое. Если исключение никто не ловит, программа упадет, и бросать исключение надо в ожидании, что его никто не ловит, то есть пусть падает, все равно уже «дальше жить незачем». В остальных случаях нужно возвращать код ошибки.


      1. nolane
        19.06.2017 00:22

        Если "пусть падает", то юзайте сразу terminate. Зачем в таком случае исключение бросать?


        1. madcomaker
          19.06.2017 00:58
          +2

          Во-первых, в случае исключения отработают деструкторы, и не говорите, что мол все равно падать, поэтому давайте оставим открытые сокеты, висящие коннекты, залоченные файлы, кучу мусора в папке temp и все такое прочее. Во-вторых, то, что некий модуль считает фатальной ошибкой, на высшем уровне иерархии может таковой не являться (например, есть другой модуль, решающий ту же задачу иначе). С этой точки зрения опять-таки важно, чтобы отработали деструкторы и программа осталась в состоянии, из которого возможен «второй круг». В-третьих, даже если программа не знает, что делать дальше, она все-таки поймает исключение на самом верхнем уровне, отобразит соответствующее сообщение, запишет причину в лог, отправит отчет об ошибке и т.п. terminate это «школьный крэш», сразу показывающий уровень команды, такое чудо разработавшей (либо уровень пофигизма в этой команде, что в принципе одно и то же).


          1. nolane
            19.06.2017 10:59

            Да, буду говорить если падать, то давайте оставим открытые сокеты, висячие коннекты, залоченные файлы, кучу мусора в папке temp. Что в этом плохого? Система всё почистит. Это в любом случае возможный исход работы программы. Представьте что будет если внезапно отключить питание.


            Раскрутка стека нужна, только если программа будет продолжать работать. Если падаем, то раскручивать смысла нет.


            Что такое "второй круг"?


            Вы сказали исключение никто не ловит, а теперь у вас уже какие то логи.


            1. madcomaker
              19.06.2017 13:44

              Что в этом плохого?

              Представьте что будет если внезапно отключить питание.

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

              Что такое «второй круг»?

              то, что некий модуль считает фатальной ошибкой, на высшем уровне иерархии может таковой не являться (например, есть другой модуль, решающий ту же задачу иначе)


              Вы сказали исключение никто не ловит

              Я не говорил такого. Я сказал, что бросать исключение нужно в расчете, что его никто не ловит. А ловит ли его кто-то, из места, где оно бросается, не видно. От того, что некая клетка организма умерла, вовсе не следует, что и весь организм должен за ней последовать. Но клетка для себя — умерла.


              1. nolane
                19.06.2017 17:04

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

                то, что некий модуль считает фатальной ошибкой, на высшем уровне иерархии может таковой не являться (например, есть другой модуль, решающий ту же задачу иначе)

                Мы говорим про "обычный рабочий момент" или про "дальше жить незачем"?


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

                Неуместное сравнение.


                1. madcomaker
                  19.06.2017 18:09

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


                  1. nolane
                    19.06.2017 18:48
                    +1

                    Не понимаю, чего вы от меня добиться хотите.

                    Хочу, что б вы признали, что исключения, которые никто не ловит, бессмысленны.


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

                    Может вы перестанете сыпать эпитетами и скажете, наконец, что конкретно не так с отсутсвием освобождения ресурсов при завершении программы?


                    1. madcomaker
                      19.06.2017 20:22

                      Исключения, которые никто не ловит, это единственный способ аварийно завершить программу, написанную на C++ (а не на C). Но я еще раз повторю, речь шла не об этом.

                      Конкретно. Как будет закрыто TCP-соединение, если оно существовало в момент заверешения? Так оно должно закрываться? Как будет завершена сессия TLS поверх этого соединения? Так она должна завершаться? Что станет со встроенной базой данных на sqlite? Что сделается с криптоключами, созданными программой? Чем закончится асинхронная запись в файл? Даже попросту что произойдет с иконкой программы в трее?


                      1. nolane
                        19.06.2017 22:59

                        Исключения, которые никто не ловит, это единственный способ аварийно завершить программу, написанную на C++ (а не на C). Но я еще раз повторю, речь шла не об этом.

                        Ага, как же. terminate, exit, return -1 из main в конце концов. А о чём тогда?


                        Конкретно. Как будет закрыто TCP-соединение, если оно существовало в момент заверешения? Так оно должно закрываться? Как будет завершена сессия TLS поверх этого соединения? Так она должна завершаться? Что станет со встроенной базой данных на sqlite? Что сделается с криптоключами, созданными программой? Чем закончится асинхронная запись в файл? Даже попросту что произойдет с иконкой программы в трее?

                        Почаще задавайте себе такие вопросы. TCP-соединение отвалится по таймауту, соответственно, и tls сессия. С sqlite все будет отлично. Там транзакционноссть гарантирует целостность данных. Про ключи не понял. С иконкой, действительно, беда, но это в первую очередь демонстрирует кривость windows.


                        Стоит уточнить, что я не утверждаю, что исключения не нужны, что terminate можно использовать где угодно, и, конечно, что ресурсы не нужно освобождать перед завершением работы программы. Я хочу скзать, что в случаях, когда ошибку обработать невозможно, к примеру при инициализации глобальных объектов, нужно вызывать terminate, а не бросать какие-то исключения.


                        1. madcomaker
                          20.06.2017 00:12

                          Пусть вы автор класса

                          class A
                          {
                              A()
                              {
                                  // 1
                              }
                          };
                          

                          Вот так я его буду использовать
                          static A a_static;
                          int main(int, char **)
                          {
                              A a;
                              // do something with a and a_static
                              return 0;
                          }
                          

                          Впишите, пожалуйста, в конструктор класса A (где комментарий с цифрой 1), как вы будете разбираться, где вас инстанцировали глобальным объектом, а где автоматическим, и, в предположении, что свершилось страшное, завершите мне программу двумя разными способами, через исключение и через terminate.


                          1. nolane
                            20.06.2017 00:25

                            Я не понял, что вы хотели сказать фразой


                            бросать исключение надо в ожидании, что его никто не ловит, то есть пусть падает, все равно уже «дальше жить незачем».

                            По-вашему, если в этом конструкторе произдёт ошибка, то исключение бросать не нужно что ли? Ерунда какая-то.


                            1. madcomaker
                              20.06.2017 00:51

                              Конструируемому объекту и правда жить дальше незачем, раз он даже родиться толком не сумел. Конструктор смело бросает исключение. Речь-то шла о другом, о том, возвращать ли код ошибки или бросать исключение из какого-то метода класса. Я написал, что бросать исключение нужно, когда «дальше жить незачем», т.е. ситуация такая же, как в конструкторе, безвыходная. В ожидании, что никто не ловит, — потому что есть идиотская практика бросать исключение там, где ничего исключительного не произошло, заставляя вызывающий код почем зря оборачивать вызов в try-catch, чтобы выполнить одну из ОЖИДАЕМЫХ веток кода. Объяснил?


                              1. nolane
                                20.06.2017 01:11

                                Да


          1. 0xd34df00d
            23.06.2017 21:45

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

            Только если оно на самом деле где-то ловится. Убегающее за пределы main исключение — это UB, и если компилятор может это доказать, то будет больно:


            #include <iostream>
            
            struct Foo
            {
                Foo () { std::cout << "yay ctor" << std::endl; }
                ~Foo () { std::cout << "yay dtor" << std::endl; }
            };
            
            int main ()
            {
                Foo foo;
                throw 0;
            }

            Есть все шансы, что yay dtor вы не увидите.


            1. khim
              23.06.2017 22:28

              Убегающее за пределы main исключение — это UB
              С какого перепугу? Вы про std::terminate вообще что-нибудь слышали?

              Есть все шансы, что yay dtor вы не увидите.
              Это почему ещё? Да, компилятор может «просечь», что в этой программе обязательно будет вызван деструктор, а за ним — std::terminate и вместо компиляции сложных таблиц и всего прочего просто напрямую вызвать Foo::~Foo() (хотя я таких компиляторов не знаю), но просто выкинуть его — он права не имеет: код Foo::~Foo() обязан быть вызван до std::terminate, а использование std::endlобязано вызвать flush(), так что yay dtor вы увидите точно. Вот что вы увидите потом — это уже зависит от компилятора, а также от того, что программа установила (если установила) в std::terminate_handlerе…


              1. 0xd34df00d
                23.06.2017 22:37
                +1

                Позор на мои патлы.


                Да, std::terminate обязан вызваться, но вот произведётся ли в этом случае раскрутка стека (и вызовы деструкторов, да) — implementation-defined.


                Так что истина где-то посередине.


                И запустите код-то.


                1. khim
                  24.06.2017 00:02

                  Implementation-defined и undefined — это две большие разницы. На тех платформах, где я работаю стек раскручивается и деструкторы вызываются. В том числе в вашем примере. Интересно какие платформы имели в виду разработчики стандарта, где этого не происходит.


                  1. 0xd34df00d
                    24.06.2017 01:17
                    +1

                    Ну да, потому и позор на мои патлы. У меня почему-то в голове отложилось, что это аж целое UB, а не что лишь вопрос вызова деструкторов и раскрутки стека — implementation-defined.


                    На линуксах под gcc и clang с libstdc++ деструкторы не вызываются: пруф.


            1. madcomaker
              23.06.2017 22:44

              Как «вендовоз» могу сказать, что конкретно в MSVC исключения подхватываются в виде uncaught в любом случае, даже будучи брошенными до main(), просто CRT так устроен. А с точки зрения стандарта насчет UB в этом случае не знаю. Если не затруднит, можно ссылочку, где это сказано, гугль что-то не захотел меня просветить.


        1. mayorovp
          19.06.2017 09:59

          Вы невнимательно читаете между слов:


          и бросать исключение надо в ожидании, что если его никто не ловит, то есть пусть падает

          terminate плох тем, что его нельзя нормально поймать, никак. Если он произошел — то уже все. Смысл исключений — в том, что их можно поймать, но если исключение не поймали — то лучше упасть чем продолжить работу.


          1. nolane
            19.06.2017 11:04
            +1

            Там нет "если". Да и странное предложение у вас получается. Что тут ожидать? Ясно что упадёт, если не поймают. Вот исходная фраза понятна, но я ней не согласен. Бросать надо только в надежде, что кто то поймает и обработает. Если ситуацию обработать невозможно, нет смысла и бросать исключение.


            1. mayorovp
              19.06.2017 12:07

              Ясно что упадёт, если не поймают.

              В случае кодов возврата это не так. И это зачастую сильно запутывает отладку.


            1. 0xd34df00d
              23.06.2017 21:48
              +2

              А это не библиотечному коду решать, можно обработать ситуацию или нет.


              1. nolane
                24.06.2017 16:06

                Кто сказал, что речь о библиотеке?


                1. 0xd34df00d
                  25.06.2017 04:14
                  +3

                  Заведомо завязываться на то, что делают вышестоящие слои, особенно там, где можно на это не завязываться — это, ну, не стоит так делать, уровни абстракций нарушаются.


      1. orcy
        19.06.2017 07:03
        +4

        > Мода тут ни при чем.

        В модных языках типа go/rust свой появился свой взгляд на то как обрабатывать ошибки, потому и спрашиваю. В статье например исключение бросается когда файл не найден внутри fopenX, и мне такое подход для общего случая не очень нравится, ведь отсутствия файла это обычно ожидаемая ситуация а не исключительная.


        1. madcomaker
          19.06.2017 13:48

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


          1. mayorovp
            19.06.2017 13:54

            Только строго наоборот. Исключение (exception) — это особая (исключительная) ситуация, которая требует особой же обработки. А ошибка (fault) — это ситуация, когда программа уже работает неправильно, и исправить тут ничего нельзя.


            1. madcomaker
              19.06.2017 14:44
              +1

              Я бы разделил на ошибки (errors), ловушки (traps) и неудачи (faults). Первое — нормальное выполнение (например, отсутствует файл, сервер ответил 5xx и т.п., ничего именно исключительного нет), второе — непредусмотренная ситуация, неразрешимая на данном уровне (например, отсутствует ветка программы в реестре, out of memory и т.п.), третье — ошибка в коде (выход за границу массива и т.п.). Первое — один из нормальных путей в вызывающем коде (if/else), второе — вызывающий код на такое не рассчитан, но может передать выше для принятия решения (например, ветку в реестре можно создать и начать заново «с самого верха»), третье — однозначно крэш как меньшее из зол.


        1. sergey_shambir
          19.06.2017 15:19
          +1

          В прикладном коде удобно использовать исключения. Идеологически можно обосновать так:


          • при написании системного сервиса или веб-сервера отказ — это типичная ситуация, и коды возврата будут хороши, а ещё лучше, на мой взгляд, std::expected
          • при написании прикладной бизнес-логики отказ означает провал всей операции (либо целого варианта её выполнения), и исключение — это лучший способ раскрутить стек до того момента, где мы можем сообщить пользователю об ошибке либо обнаружить известную проблему и пойти по запасному плану

          Опять же, если есть удобный шаблон вида std::expected, то можно написать вариант fopenX без выброса исключений. Будет примерно как в Rust/Go.


  1. fareloz
    18.06.2017 16:21
    +2

    Кст в первых двух блоках кода показана очень распространенная проблема форматирования кода — избыточные уровни вложения. Всегда пытаюсь бороться с лишними уровнями вложения. Каждое условие выбрасывает исключение, то есть прерывает выполнение. Это делает ненужным последующие else.


  1. mbait
    18.06.2017 17:52
    +2

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

    Нет, вы тоже не знаете. Правильный вариант:


     auto f = std::unique_ptr<FILE, decltype(&std::fclose)>(std::fopen(path, mode), &std::fclose);

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

    Разве что, "джуниоры". Остальные разработчики проверяют булево состояние потока вначале (необязательно) и при каждой операции чтения:


    std::ifstream fs ( "foo.txt" );
    if (!fs) std::cerr << std::strerror(errno); // можно, если очень хочется, но не обязательно
    std::string s;
    while (getline(fs, s)) { ... }


    1. sergey_shambir
      19.06.2017 16:10
      +2

      Я не могу назвать такую специализацию unique_ptr правильным вариантом, потому что всегда придётся в конструктор передавать второй аргумент, т.к. подходящего дефолтного конструктора у указателя на функцию нет. Поэтому и заводят структуру с одним оператором.


    1. Readme
      21.06.2017 13:19

      Нет, вы тоже не знаете. Правильный вариант:
      Всё-таки автор более корректен в объявлении deleter'а: во-первых, как было отмечено, второй аргумент теперь будет являться обязательным в конструкторе, а во-вторых, размер такого unique_ptr'а раздувается на размер хранимого указателя на функцию-удалитель (несмотря на то, что fclose по сути является глобальной свободной функцией). Сравнение размеров.
      Если такой unique_ptr является членом RAII-класса, то размер может иметь значение. Однако если обработка выполняется только в фиксированном {}-скопе (т.е. C-указатель не покидает блока), действительно можно обойтись указанным объявлением, хотя выигрыш в количестве кода при хорошем форматировании сомнителен.


  1. FancyRansomware
    19.06.2017 16:01
    -1

    Код первого примера

    ....
    HANDLE fileLock = CreateFileW(
    ....
     if (!fileLock)
     {
       CloseHandle(fileLock);
    ....
    

    Баг №1:
    CreateFileW не умеет возвращать нулевое значение совсем (см MSDN). В слечае ошибки возвращается INVALID_HANDLE_VALUE которое суть (HANDLE)(-1). Условие не сработает никогда.

    Баг №2:
    Если вдруг Windows совсем заболеет и CreateFileW всё-же вернёт 0 то в условии вы закрываете нулевой хэндл.
    На этот случай CloseHandle может кинуть exception invalid handle.

    Читать дальше перехотелось.



  1. federix
    19.06.2017 16:01

    Даа модули это хорошо…


  1. kovserg
    19.06.2017 16:02
    -1

    Вот почему в ядре linux пишут на C, а не на C++.
    Если уж с открытием фала такой геморой, представлю что там со всем остальным.
    Если вы не доверяете программисту пишите на Go.


  1. dadwin
    19.06.2017 16:02

    есть три недостатка: она принимает параметры по указателям

    ОК, но ваша fopen5 тоже принимает параметр по указателю — const char* mode. или есть на то причина? как с ним быть?

    с модулями компиляция (без компоновки) станет намного быстрее

    а что именно станет быстрее?


    1. sergey_shambir
      19.06.2017 16:03

      Я думаю мне стоило заменить const char* mode на const char mode[]. Для анализатора это может дать верную подсказку.
      P.S. с модулями все заголовки STL будут разбираться только один раз, а сейчас без хороших precompiled headers они разбираются парсером каждый раз при компиляции очередного файла. Частично precompiled headers решают эту задачу, но хранение на диске в виде интерфейса модуля будет компактнее, чем хранение в виде сериализованного AST. Т.е. модули грозят быть быстрее precompiled headers в плане нагрузки на I/O между запусками компилятора системой сборки, кроме того, суженному интерфейсу легче поместиться в уровни кеша процессора.


  1. Nick_Shl
    19.06.2017 16:06

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


    1. sergey_shambir
      19.06.2017 16:07

      Если вместо //..остальной код вставить реальный код, бросающий исключение, то уже открытый ранее файл никем не будет закрыт. Я даже встречал связанный с этим баг в одной из библиотек: при ошибке чтения PNG из файла файл становился заблокированным на чтение самим же процессом, потому что где-то в библиотеке утёк FILE*.


      1. Nick_Shl
        23.06.2017 02:45

        Дошло. Сбило с толку это:

                if (!in)
                {
                    throw std::runtime_error("open failed");
                }
        


  1. Luke0208
    19.06.2017 16:08
    -3

    > не пишите низкоуровневые циклы for и while
    > используйте алгоритмы и другие средства из STL/Boost
    Зачем вы так с новичками? А если они поверят что STL/Boost это хорошо?
    Вырезка из википедии(цитата Торвальдса)
    С++ приводит к очень, очень плохим проектным решениям. Неизбежно начинают применяться «замечательные» библиотечные возможности вроде STL, и Boost, и прочего мусора, которые могут «помочь» программированию, но порождают:
    — невыносимую боль, когда они не работают (и всякий, кто утверждает, что STL и особенно Boost стабильны и портируемы, настолько погряз во лжи, что это даже не смешно)
    — неэффективно абстрагированные программные модели, когда спустя два года обнаруживается, что какая-то абстракция была недостаточно эффективна, но теперь весь код зависит ото всех окружающих её замечательных объектных моделей, и её нельзя исправить, не переписав всё приложение.


  1. ivan_petroff
    19.06.2017 16:08
    -2

    Страсть, как ненавижу иностранный словечки. Джуниор: Junior — имеет 5 разных значений в зависимости от страны(Британия или США).

    Можно просто новичок или начинающий?


  1. Mehazawa
    19.06.2017 16:08

    >auto f = std::unique_ptr<FILE, decltype(&std::fclose)>(std::fopen(path, mode), &std::fclose);

    Выглядит четко. Только вот состояние unique_ptr придётся еще дополнительно проверить, легче обернуть fopen в функцию и выкинуть exception на конструкции указателя, чем проверять потом его состояние.

    >Разве что, «джуниоры». Остальные разработчики проверяют булево состояние потока вначале (необязательно) и при каждой операции чтения:
    >необязательно
    А как же выбросить exception на свалившееся открытие? У автора написано грамотнее.