- PVSM.RU - https://www.pvsm.ru -
Мы все ценим C++ за лёгкую интеграцию с кодом на C. И всё же, это два разных языка.
Наследие C — одна из самых тяжких нош для современного C++. От такой ноши нельзя избавиться, но можно научиться с ней жить. Однако, многие программисты предпочитают не жить, а страдать. Об этом мы и поговорим.
Не так давно я случайно заметил в своём любимом компоненте новую вставку. Мой код стал жертвой Tester-Driven Development.
Согласно википедии [1], 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);
}
Давайте опишем словесно, что делает функция:
Вам не кажется, что кое-что тут выпадает из уровня абстракции функции?
Не смешивайте слои абстракции, код с разным уровнем детализации логики должен быть разделён границами функции, класса или библиотеки. Не смешивайте 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 функция fopen
не возвращает информации об ошибке в кодах возврата. Далее, out-параметры в C передаются по указателю, а в C++ программиста за такое могут и отругать. Далее, в C++ есть идиома RAII для управления ресурсами.
Мы не будем перечислять остальные отличия. Просто примем как факт, что мы, C++ программисты, пишем на C++ и вынуждены использовать API в стиле C ради:
Но использовать не значит "пихать во все места"!
Если вы используете 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 для управления ресурсами. Но мы-то знаем:
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);
}
У такой функции ещё есть три недостатка:
Если вызвать функцию для несуществующего пути и для пути к каталогу, получим следующие тексты исключений:
Во-первых мы должны узнать у ОС причину ошибки, во-вторых мы должны указать, по какому пути она возникла, чтобы не потерять контекст ошибки в процессе полёта по стеку вызовов.
И тут надо признать: не только джуниоры, но и многие мидлы и синьоры не в курсе, как правильно работать с 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);
}
Если вызвать функцию для несуществующего пути и для пути к каталогу, получим более точные тексты исключений:
C++17 принёс множество маленьких улучшений, и одно из них — модуль std::filesystem
. Он лучше, чем boost::filesystem
:
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 будут с меньшей задержкой реагировать на автодополнение.
Хотя в индустрии правит ООП, а в академическом коде — функциональный подход, фанатам процедурного стиля пока ещё есть чему радоваться.
fopen4
по-прежнему использует флаги, mode и другие фокусы в стиле C, но надёжно управляет ресурсами, собирает всю информацию об ошибке и аккуратно принимает параметрыЯ рекомендую все функции стандартной библиотеки C, WinAPI, CURL или OpenGL завернуть в подобном процедурном стиле.
На C++ Russia 2016 и C++ Russia 2017 замечательный докладчик Михаил Матросов показывал всем желающим, почему не нужно использовать циклы и как жить без них:
Насколько известно, вдохновением для Михаила служил доклад 2013 года "C++ Seasoning" [4] за авторством Sean Parent. В докладе было выделено три правила:
Я бы добавил ещё одно, четвёртное правило повседневного C++ кода. Не пишите на языке Си-Си-Плюс-Плюс. Не смешивайте бизнес-логику и язык C.
Причины прекрасно показаны в этой статье. Сформулируем их так:
Только настоящий герой может написать абсолютно надёжный код на C/C++. Если на работе вам каждый день нужен герой — у вас проблема.
Автор: Сергей Шамбир
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-3/258091
Ссылки в тексте:
[1] Согласно википедии: https://en.wikipedia.org/wiki/Tester_Driven_Development
[2] Повседневный С++: boost и STL: http://cpp-russia.ru/?page_id=999
[3] Повседневный С++: алгоритмы и итераторы: http://2017.cppconf.ru/talks/mikhail-matrosov
[4] "C++ Seasoning": http://sean-parent.stlab.cc/papers-and-presentations
[5] С++ without new and delete: http://cpp-russia.ru/?page_id=608
[6] Источник: https://habrahabr.ru/post/331100/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.