Pull to refresh

Comments 83

Сухая статья, мало примеров и пояснений. Понятно будет разве что тем, кто и так всё это знает.
Спасибо! Да, статья рассчитана на опытного программиста. Стиль конспективный, иначе объем стал бы чрезмерно большим. Моя задача была дать опытному программисту варианты выбора при проектировании классов, управляющих ресурсами, описать некоторые тонкости и подводные камни.
Огромное спасибо за статью! Именно такой стиль изложения и был мне необходим ибо знания есть, но вот поделиться ими тяжело без подобного конспекта, а отсылать читать 100500 книжек и заметок в 100500 блогах бесполезно. Привести собственные заметки к единому и цельному виду тоже не получилось… А теперь и не надо! Спасибо!
Я не являюсь опытным программистом на C++, но сказал бы, что статья содержит совсем базовые вещи.
опытным программистам тоже частенько приходится освежать базу. Часто много нового узнаешь.
class X
{
public:
    X(const X&) = delete;
    X& operator=(const X&) = delete;
    X(X&& src) noexcept;
    X& operator=(X&& src) noexcept;
// ...
};


Запрещать копирование не нужно, т.к. при наличии перемещающих конструктора и оператора присвоения компилятор не станет генерировать их копирующие аналоги.

Это как раз правило хорошего стиля: "Если определяешь или =delete любую операцию по умолчанию, то определи или =delete их всех".

Учитывая, насколько часто меняются правила при смене стандарта, то да, имеет смысл так делать.

Вообще, правило хорошего тона — это не использовать пользовательские конструкторы копирования и перемещения вообще. В этом случае компилятор их сгенерирует автоматически. Определение этих конструкторов явным образом нужно разве что при разработке библиотек базовых классов.
Это мнение Саттера, как частного лица. Если бы это было мнение комитета по стандартизации, это было бы в стандарте. Мотивировка — откровенно слабая:
So as soon as any of the special functions is declared, the others should all be declared to avoid unwanted effects like turning all potential moves into more expensive copies, or making a class move-only.

Т.е. запрещайте, чтобы избежать неких «нежелательных эффектов». А никаких эффектов и без вмешательства кодера не будет, поведение компилятора в этом случае выглядит вполне разумным. Ну и традиционное, как пьяный дед мороз под новый год, «explicit is better than implicit»:
Defining only the move operations or only the copy operations would have the same effect here, but stating the intent explicitly for each special member makes it more obvious to the reader.


P.S. лично я предпочитаю не загаживать исходный код вещами, ничего не дающими ни кодеру, ни компилятору
UFO just landed and posted this here
Спасибо, не обратил внимания! Но при кодировании я придерживаюсь правила: как можно меньше использовать всяких правил по умолчанию, так как они снижают читаемость кода и иногда довольно запутанны, что усугубляет ситуацию. Так, что я почти всегда использую конструкции "=default" и "=delete", даже если их можно опустить.
UFO just landed and posted this here
Я сейчас готовлю статью, где (в том числе) обсуждается потенциальная опасность ситуации, когда функции-члены генерируются компилятором. Про проблемы перемещения, генерируемого компилятором, подробно пишет Скотт Мейерс. Я горячий сторонник все делать явно.
Честно говоря, при всем уважении к объёму написанного, выглядит просто как компиляция информации из всех возможных источников, или как какая-то лекция по C+ в институте.
Все это и так можно найти где угодно, и про RAII, и про move-rvalue. Не очень ясна цель поста — было б интереснее увидеть какие-то особенности или малоизвестные детали копирования/перемещения: например, RVO/NRVO, enable_shared_from_this, вопросы многопоточности shared_ptr (хотя такая статья точно здесь есть)
Компиляции тоже нужны, тем более эта выполнена качественно. Мне бы эта статья лет этак 8 назад весьма бы подсократила мой (ныне оставленный) путь C++ программиста
Спасибо! В действительности самая первая версия этой статьи где-то 8 лет назад и появилась. Но пришел С++11 с его семантикой перемещения и многое пришлось пересмотреть, эта моя статья несомненно рекордсмен по продолжительности написания.
Спасибо! Искать «научную новизну» в текстах по программированию, мне кажется, не совсем правильно. А рассказ про RVO, RAII и даже семантику перемещения это не цель, а просто необходимый фон для освещения главной цели статьи: рассказать как правильно проектировать классы, управляющие ресурсами.
UFO just landed and posted this here

Труд похвальный, но сам Страуструп уже над таким работает более масштабно: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#main


Не совсем вижу смысл в "конкуренции" без фактического предложения альтернативы. Может есть смысл заняться переводом?

Спасибо! Я подозревал, что могу быть не оригинальным, но эта статья выстрадана, я с ней возился несколько лет. А Страуструпа обязательно посмотрю.
x.DoIt();   // DoIt() &
X().DoIt(); // DoIt() &&

Не зря дочитал до конца. Спасибо!
Мне концепция RAII не очень приятна. Её можно эффективно использовать для совсем простых сущностей, но для сложных лучше использование методов Create и Destroy, а не конструкторов и деструкторов. Аргументация:

1. В конструкторах и деструкторах нельзя (на самом деле можно, но в большинстве случаев это будет выстрелом в ногу) вызывать виртуальные методы. А иногда очень хочется это делать.

2. При конструировании объекта единственным способом сообщить о неудаче является исключение. А это не всегда удобный механизм (про производительность вообще молчу).

3. Из деструкторов вообще нельзя бросать исключения. Если что-то пошло не так, то программе придётся упасть, тогда как при неудачном вызове Destroy есть шанс отработать корректно.

4. Конструирование объекта может занимать длительное время, например, если объект — готовое сетевое соединение с базой данных. Если вы хотите передать код с синхронного на асинхронный, то в случае RAII вам придётся его полностью переписывать.

Резюме: если создание и разрушение объекта всегда производится без ошибок, а ошибка является фатальной, то используйте RAII. Если ошибка при создании объекта является штатной ситуацией, то RAII не нужен.
Использование фабрик и RAII — вещи не конкурирующие, а дополняющие друг друга.

Ну и позволю себе чуток уточнить по вашим пунктам)

1. На самом деле нельзя) Если вы хотите вызвать из конструктора/деструктора виртуальный метод, то вы хотите странного.

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

3. Полностью согласен, если Destroy вызывается не из деструктора какого-нибудь объекта в каком-нибудь фреймворке)

4. Если объект — готовое сетевое соединение, то он собственно уже готов, его осталось обернуть в RAII. Переделать код с синхронного на асинхронный — это все равно все к чертям переписать, и RAII там тоже нужен, но по-другому)

Как-то так)
И таки тем самым странным можно назвать Curiously recurring template pattern (CRTP). Но да, так или иначе вызывать CRTP-методы придется после конструирования наследников.
1. На самом деле нельзя) Если вы хотите вызвать из конструктора/деструктора виртуальный метод, то вы хотите странного.
Оконная библиотека. У вас есть абстрактный класс, который умеет группировать объекты и его многочисленные потомки — диалоговые окна, тулбары и прочее. Вам в конструктуре было бы неплохо узнать — а какой, собственно, будет размер «внутренностей» у вашего окна (тулбар и обычный диалог тут устроены сильно по разному, как вы понимаете). В Turbo Vision для Turbo Pascal — это сделано естественным образом, через виртуальные функции. В Tubro Vision для С++ — там костыль. Можно выбрать из несколькоих — но это всё равно будет костыль.

Хуже того, ради того, чтобы предотвратить это «преступление» инициализация каждого объекта делает бессмысленные действия — независимо от того, нужно вам это или нет. Это в языке, который постулирует себя как «платите только за то, что используете», да.

Да и вообще этот запрет, якобы не позволяющий «сделать себе плохо», смотрится дико посреди языка, представляющего собой большую коллекцию ногострелов разных сортов и размеров.

Что же касается конструкторов-деструкторов, то там много разного бывает, но это к языку имеет мало отношения, это просто общая проблема: часто понять — что делать, когда произошла обшибка тяжело из-за специфики области применения. Тут RAII может как помогать, так и мешать.

Особенно тяжело, действительно, обрабатывать случаи, когда «закрытие ресурса» может сорваться и, после этого, это можно как-то купировать…
В с++ есть очень важная гарантия — методы класса не могут быть вызваны перед конструктором и после деструктора. Виртуальные методы в конструкторе/деструкторе родителя эту гарантию нарушает. И я, например, хочу эту гарантию гораздо больше, чем решить вашу конкретную проблему чуть более элегантным (как вам кажется) способом.
В с++ есть очень важная гарантия — методы класса не могут быть вызваны перед конструктором и после деструктора.

Нету таких гарантий. Вот вам пример.

В с++ есть очень важная гарантия — методы класса не могут быть вызваны перед конструктором и после деструктора.
Во-первых все ваши гарантии выеденного яйца не стоят, если в программе есть UB (это вам уже показали). Я если вы всегда пишете без ошибок — то нафига вам какие-либо гарантии.

Во-вторых если хотите без UB — то это тоже бывает. Ну или вот так.

Как я уже сказал — ногострелов в C++ более, чем достаточно, для того, чтобы можно было попадать себе в ногу многими интересными способами.

Виртуальные методы в конструкторе/деструкторе родителя эту гарантию нарушает.
А также их нарушает куча других конструкций. И? Почему мы вот один конкретный способ стерльбы по ногам закрыли (причём закрыли путём усложенения компилятора и рантайма, а не просто как-нибудь), и при этом кучу других методов — оставили?

И я, например, хочу эту гарантию гораздо больше, чем решить вашу конкретную проблему чуть более элегантным (как вам кажется) способом.
Он не самый элегантный. Он самый естественный. И во всех языках, которые не прилагали специалиных усилий (C#/Java, Object Pascal, далее везде) он работает. А вот в C++ — его закрыли. Ради каких-то странных гарантий, которые всё равно можно нарушить!

Вот в каком-нибудь языке типа Java (без Undefined Behavior, шаг влево, шаг право — попытка к бегству) это смотрелось бы допустимо. Но вот конкретно в C++ — это смотрится, по меньше мере, уродливо.
Хм, да, спасибо, я действительно поспешил (да ещё и сформулировал коряво).

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

Кстати, вы похоже смотрели на другие языки. А как там решают данную проблему?
Однако вы не согласны, что это некие вырожденные примеры?
Да, конечно. Но вырожденными они являются только потому что виртуальные функции там вызывать всё равно нельзя.

А вот например ловить ошибку того, что я использую в своей функции переменную, у которой не сработал конструктор — гораздо больший гемор.
Не больший гемор, чем когда вы используете default initialization и обнаруживаете, что у вас в переменной мусор. MSAN точно так же смог бы это отловить.

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

Кстати, вы похоже смотрели на другие языки. А как там решают данную проблему?
Никак не решают. Потому что это — не проблема. «Конечный» конструктор (не вызванный из других) прописывает vtable — и всё, объект «готов к труду и обороне». До того, как сработает первая строчка пользовательского кода. В Java для безопасности все остальные поля обнуляются.
«Конечный» конструктор (не вызванный из других) прописывает vtable

А как конструктор узнает, что он конечный?


Скорее указатель на vtable прописывает вызывающий код

У разных конструкторов имена разные. Можете посмотреть тут:
::= C1 # complete object constructor
::= C2 # base object constructor
::= C3 # complete object allocating constructor

Может и вызывающий код прописывать — это от реализации зависит.

Главное, что текущее поведение — это в чистом виде усложнение и замедление. Дающее весьма мало преимуществ. Я, во-всяком случае ни разу не видел применения этой «фичи» на практике.

А от невозможности использовать виртуальные функции в конструкторе страдал часто.
Нет. Инициализация по-умолчанию и отсутствие инициализации — принципиально разные вещи. Вот пример.
При конструировании Base вызывается foo, которая в свою очередь (если бы вызывалась foo из Derived) вызывает функцию класса A. При конструктор A ещё не был вызван. И как бы вы ловили такое? Тем более что вызов foo вполне мог бы появится внезапно для автора Derived.
Заголовок спойлера
struct A{
    A(){
        // some important work
    }
    void do_work(){
        // need internal initialized vars
    }
};
 
struct Base{
    Base(){
        foo();
    }
    virtual void foo() { }
};
 
struct Derived : Base{
    Derived() = default;
    virtual void foo() override {
        a.do_work();
    }
private:
    A a;
};
 
 
int main(){
    Derived derived;
}


UFO just landed and posted this here
В примере @ a1ien_n3t оно обязательно. E->T интерпретируется как (*E).T в отсутсвии перекрытого operator->, а разименовывать nullptr нельзя. Если operator-> то там другие правила, но он, в свою очередь, не может быть статическим, так что UB случится всё равно.

Как обычно — что и как сделает программа встретившись с UB никто не знает.
UFO just landed and posted this here
То, что из конструкторов можно дёргать методы — это не фокус. Фокус в том, что в C++ есть конструкции, позволяющие вызывать методы у ещё не сконструированного объекта. У которого конструктор ещё не вызвался. И там даже подробно специфицировано — как это работает.
UFO just landed and posted this here
Вам в конструктуре было бы неплохо узнать — а какой, собственно, будет размер «внутренностей» у вашего окна


я может быть туплю, но мне показалось, что вы хотите вызвать в конструкторе виртуальные методы объектов-агрегатов. В этом случае — никаких ограничений. А вот вызов виртуального метода самого объекта из конструктора противоречит просто здравому смыслу, так как предполагает выполнение кода потомка до вызова его конструктора
1. Язык такую возможность позволяет — значит, можно. Пример: виртуальный метод tostring() и логирование, которое этот метод использует. Хочется использовать логирование и в конструкторе, и в деструкторе.

2. В таком случае все равно придётся выделять память под объект, плюс непонятно, как передавать код ошибки. Одним из полей объекта — неправильно. Пара (ссылка на объект, код ошибки), возвращаемая фабрикой, будет более логичным решением.

4. Я имел в виду, что на входе — параметры подключения, на выходе — установленное соединение, т.е. логика создания объекта очень сложная. В том же C# для переписывания синхронного кода в асинхронный требуется минимум телодвижений. Даже автоматические средства существуют.
1. Язык такую возможность позволяет — значит, можно. Пример: виртуальный метод tostring() и логирование, которое этот метод использует. Хочется использовать логирование и в конструкторе, и в деструкторе.
Вот как раз логирование приводится как пример того, для чего всё это безумие нужно. Ибо так, как это реализовано в C++ — внутри конструктора A вы можете использовать логирование, но оно ничего не будет знать о том, что этому объекту суждено в будущем стать объектом B. И в деструкторе — тоже. вот так, типа безопасно.

Вот только на практике мне этим не пришлось воспользоваться ни разу, а вот с ошибками из-за «странного» поведения виртуальных функций — я регулярно сталкиваюсь.

2. Пара (ссылка на объект, код ошибки), возвращаемая фабрикой, будет более логичным решением.
А что будет с объектом, которые не до конца сконструирован? Вызывать для него деструктор или нет? А если что-то удалось, что-то нет?

Это придётся очень сильно язык менять. Сейчас все эти краевые случае решаются как частный случай обработки исключения.
А что будет с объектом, которые не до конца сконструирован? Вызывать для него деструктор или нет? А если что-то удалось, что-то нет?

Предполагается, что объект либо конструируется полностью, либо не создаётся вообще. Очистка ресурсов в случае ошибки создания объекта — забота фабрики.

Ну хорошо — пусть у вас объект наследовался от двух других. И в одном конструктор отработал, а в другом нет. В какое место вы тут вставите фабрику, которая будет это всё разруливать?

Нет, я верю что это всё можно разрулить. Но это приведёт к созданию сильно другого языка — мало похожего на современный C++.
Я составлю логику так, чтобы конструкторы всегда выполнялись успешно, т.е. конструктор будет просто передачей состояния от фабрики в объект.
C++ — внутри конструктора A вы можете использовать логирование, но оно ничего не будет знать о том, что этому объекту суждено в будущем стать объектом B. И в деструкторе — тоже. вот так, типа безопасно.

А как иначе то? Возьмем пример. Если бы A знал что ему суждено стать и перестать быть B, в A::foo будет UB — обращение к неинициализированному/удаленному объекту. А теперь представьте, что вы хотите отнаследоваться от класса, который в виртуальном деструкторе зовет несколько виртуальных же методов. Может быть всё-таки лучше как есть?
Может быть всё-таки лучше как есть?
Как есть — это как? Примерно так: берём простенькую программу, делаем небольшой рефакторинг… трах, бах, расчленёнка, кишки наружу.

Великолепный подход, ящитаю.

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

Подход C++ успешно сочетает недостатки обоих подходов.
UFO just landed and posted this here
В том же C# для переписывания синхронного кода в асинхронный требуется минимум телодвижений. Даже автоматические средства существуют.

вот это для меня — новость. Пойду гуглить) У нас (сейчас я на scala) хоть и параллелится и асинхронится все влет, но автоматических средств нема
UFO just landed and posted this here
Проблема в том, что как только вы захотите от этого класса унаследоваться вам всё равно придётся иметь дело с конструкторами.

По большому счёту хороший решений в C++ ровно два:
1. Смириться с исключениями и делать так, как разработчики предусмотрели (хотя всё равно неясно что делать с деструкторами).
2. Отказаться от конструкторов и деструкторов и иметь методы Init/Destroy.
UFO just landed and posted this here
Вот хотите вы имитировать простой человеческий with из python'а. Для файлов. И нужно вам как-то сообщить программе, что файлик закрыть не удалось (close(2) вернулся с ошибкой — и да, это реально происходит и да, это-таки ошибка которую аккуратно написанные программы, например, emacs обрабатывают).

Ваши действия?
UFO just landed and posted this here
Там же закрытие ресурса неявное, а оно у меня тут самое что ни на есть явное.
Вопрос не в «явное/неявное». Вопрос в том, чтобы случайно не забыть закрыть и при этом обработать ошибки.

А то рассказы про то, что finally не нужен, потому что есть RAII — есть, а объяснений как решить с помощью этого RAII простейшую задачу — нет. Есть много громоздких и некрасивых решений, а хорошего — я не знаю.
UFO just landed and posted this here
Композиция — наше всё.
не стоит из плюсов делать хаскель) в плюсах для исключительных ситуаций используются исключения.
UFO just landed and posted this here
Если вы ожидаете ошибку при создании объекта, то это не исключительная ситуация.
От языка зависит. В том же python итератор имеет один метод next и кидает исключение, если всё закончилось.

Но ему можно: там на производительность забили большой и толстый болт, так что не проблема. В C++ хотелось бы обойтись как-нибудь.
да, я чуток накосячил в терминологии. Исключительная ситуация — когда ничего другого не остается, кроме как форснуть ошибку выше, вплоть до падения программы. Ошибка — это то, что обрабатываем здесь и сейчас. Первое — подмножество второго. Я имел в виду, что для обработки ошибок в плюсах принято использовать исключения. Но тут еще нужно более развернуто определить, что же такое ошибка) Exceptional-based logic тоже стоит избегать, конечно же.
Исключительная ситуация — это такой класс ошибок, который не может быть обработан на текущем уровне абстракции.
UFO just landed and posted this here
И чем это отличается от того, что я написал выше? :)

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

На разных уровнях одна и та же ошибка может иметь различную семантику и стратегия восстановления после сбоя может быть разной.
UFO just landed and posted this here
По опыту скажу, что невозможность записи в файл в подавляющем большинстве случаев является ошибкой программиста (неправильно указан путь) и, соответственно, исключительной ситуацией. Реже — переполнением диска, отсутствием прав доступа и т.д., что впрочем тоже ситуации исключительные.

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

Описанный подход с Either обладает тем недостатком, что заставляет либо тащить наружу все возможные детали всех возможных ошибок, усложняя обработку ошибок, либо, наоборот, скрывать причины ошибок. Меняется реализация с новым набором ошибок — боль либо неизвестность.
Принципиальная разница в том, что механизм исключений предполагает невозможность их игнорирования. Не перехватил исключение — оно выбросится наверх. Проигнорировал код возврата — ничего не случится, но логика работы программы может быть нарушена.
Диалектика локальной и нелокальной стратегии обработки ошибок. Марксистско-Ленинская философия программизма. Т. 3, стр. 14 ))))
В таком случае никакого смысла в использовании исключений вообще нет.
UFO just landed and posted this here
UFO just landed and posted this here
UFO just landed and posted this here
Про ограниченность RAII я пишу прямым текстом, раздел 6 как раз и посвящен тому, как можно преодолеть эту ограниченность. Проблемы традиционного протокола создания/удаления объекта через конструктор и деструктор и методы их решения не обсуждал. Несомненно, что это интересная тема, но статья и так большая, пришлось себя ограничивать.
UFO just landed and posted this here
Вроде всё знал, что написано, но, когда это собрано в одном месте, то стало понятнее и логичнее.
Спасибо!
UFO just landed and posted this here
Не совсем понял, о чем речь. Если можно, поподробнее.
Sign up to leave a comment.

Articles