Мы все ценим 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;
}
}
Поскольку первый путь не существует, а второй является директорией, мы получим исключения. Вот только в тексте ошибки нет ни пути к файлу, ни точной причины. Если вы запишете такую ошибку в лог, чем это вам поможет?
Типичный код, использующий 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, '');
// читаем не более 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 напрямую
- подробнее об этом — в докладе Михаила Матросова С++ without new and delete
- не используйте низкоуровневые примитивы синхронизации, такие как mutex и thread
Я бы добавил ещё одно, четвёртное правило повседневного C++ кода. Не пишите на языке Си-Си-Плюс-Плюс. Не смешивайте бизнес-логику и язык C.
- Заворачивайте язык C как минимум в один слой изоляции.
- Если речь об асинхронном коде, заворачивайте в два слоя: первый изолирует C, второй — прячем примитивы синхронизации и шедулинг задач на потоках
Причины прекрасно показаны в этой статье. Сформулируем их так:
Только настоящий герой может написать абсолютно надёжный код на C/C++. Если на работе вам каждый день нужен герой — у вас проблема.
Автор: Сергей Шамбир