Что такое RAII?

RAII означает получение ресурсов - это инициализация, акроним (ну, на самом деле инициализм, но кто считает), который полезен только в том случае, если вы уже понимаете, что такое RAII. Наша цель - обрести это понимание. В этом посте я сначала расскажу о проблемах, которые решает RAII. Далее я объясню, что такое RAII и как он решает эти проблемы. Наконец, я покажу вам несколько примеров. Обратите внимание, что я буду говорить о RAII в контексте C ++, с которым он обычно ассоциируется. Однако он не зависит от языка и также используется на нескольких других языках.

Если вы предпочитаете посмотреть видео об этом материале, посмотрите сопроводительное видео на YouTube здесь.

Какие проблемы он решает?

Допустим, я хочу динамически инициализировать массив. По какой-то причине я забываю, что std::vector существует, поэтому пишу int *arr = new int[dynamicSize];. Все это хорошо, за исключением того, что если я забуду написать delete[] arr;, у меня будет утечка памяти. Другими словами, я должен явно управлять своей памятью, если хочу избежать утечек.

А теперь давайте подумаем о замках. Допустим, я хочу явно синхронизировать какую-то часть моего кода. Я делаю это, создавая std::mutex, а затем вызывая mutex.lock(). Это работает нормально, за исключением того, что если я забуду вызвать mutex.unlock() или возникнет исключение до разблокировки, другой мой поток зайдет в тупик, когда попытается получить блокировку.

Наконец, что насчет потоков? Допустим, я хочу запустить какой-то код в другом потоке и поэтому создаю его. Это прекрасно работает, пока я не забываю присоединиться к теме; если я этого не сделаю, и поток выходит за рамки, тогда std::terminate будет вызван и моя программа завершится.

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

Вы видите закономерность? В каждом примере мы начинаем с получения ресурса: в первом примере мы получаем память в куче; во втором примере мы получаем блокировку; в последнем примере мы получаем поток. По отдельности в этих приобретениях нет ничего плохого. Однако, когда ресурсы выходят за рамки, возникают проблемы. Мы получаем утечки памяти, взаимоблокировки и завершения работы. Эти проблемы невозможно решить; в каждом случае все, что нам нужно сделать, это написать соответствующую строку кода «unacquire», то есть написать несколько строф для очистки. Однако трудно всегда помнить об этом, и даже если мы помним, это не всегда дает безопасный код. RAII намерен решить эти проблемы.

Примечание. Явное написание этих разделов очистки (например, освобождение указателей, закрытие файлов и т. д.) нормально в C, и вы часто это увидите. C не является объектно-ориентированным и не поддерживает RAII.

Что такое Райи?

Проще говоря, RAII - это когда вы получаете ресурсы в конструкторе и освобождаете их в соответствующем деструкторе. Вот и все! Например, мы могли бы реализовать такой глупый класс RAII (на самом деле не пишите этого):

RaiiClass::RaiiClass(int *ptr) : ptr_(ptr) {}
RaiiClass::~RaiiClass() { delete ptr_; }

Что такое ресурс? вам может быть интересно. Хороший вопрос! Когда мы говорим о RAII в C ++, ресурс относится к чему-то, что должно быть приобретено перед использованием. Обратите внимание, что ресурсы ограничены. Например, кучная память, файлы, сокеты и мьютексы - все это ресурсы, и все они должны быть получены перед использованием. Это помогает мне думать об этом как об обмене: например, программа должна запрашивать у операционной системы динамическую память перед ее использованием.

Получение ресурсов в конструкторе и их освобождение в соответствующем деструкторе связывает время жизни ресурса со временем жизни объекта и делает так, что нам больше не нужно помнить о явном освобождении ресурсов всякий раз, когда они были получены. Например, вместо того, чтобы всегда писать delete ptr; всякий раз, когда мы заканчиваем с некоторой частью памяти, мы можем поместить этот код в деструктор некоторого объекта; память будет освобождаться всякий раз, когда объект будет уничтожен.

Последствия этого на удивление велики - это одна из вещей, которая отличает написание C ++ от написания C. Например, практическое правило C ++ состоит в том, что вы никогда не должны писать new или delete. Вместо этого вы должны использовать объект RAII, который управляет памятью, т.е. вы должны связать выделение и освобождение памяти с временем жизни объекта. Этому правилу обычно легко следовать, поскольку стандартная библиотека предоставляет такие классы, как std::vector и std::unique_ptr. Если эти классы не подходят для вашего варианта использования, вы можете использовать собственный объект RAII, и в этом случае new и delete будут присутствовать в реализации класса, но не в пользовательском коде. В общем, вам следует избегать написания разделов очистки и переместить такой код в деструктор объекта RAII. Конечно, из этих правил есть исключения, но о них следует помнить.

Примечание. RAII означает, что вам не нужно беспокоиться о сроке службы ресурса. Однако вам все равно нужно подумать о времени жизни объекта!

Примеры

Пришло время взглянуть на код! Я собираюсь использовать мьютексы для основного примера. Мы рассмотрим плохой пример - чего нельзя делать и как исправить это с помощью RAII. Затем мы рассмотрим несколько более коротких примеров, связанных с потоками и указателями.

Примечание. Чтобы следить за кодом, ознакомьтесь с https://github.com/arcticmatt/blogtube/tree/master/cpp/raii

Плохой пример мьютекса

В этом примере мы вручную блокируем и разблокируем мьютекс. То есть в этом примере не используется RAII, и поэтому он подвержен ошибкам.

Это та тупиковая ситуация, о которой я говорил ранее. Если вы запустите этот код, произойдет следующее:

$ clang++ -std=c++17 -o RaiiMutex RaiiMutex.cpp && ./RaiiMutex
Locking mutex manually...
Mutex is locked!
Throwing exception
Caught exception
Locking mutex manually...

Зависает после последней строчки. Это означает, что lockMutexBad не безопасен в отношении исключений; другими словами, если между блокировкой и разблокировкой возникает исключение, блокировка никогда не будет снята. Также довольно легко просто забыть снять блокировку, например, если есть несколько ранних операторов возврата.

Хороший пример Mutex # 1

В этом примере используется std::scoped_lock, класс RAII для получения блокировок. В частности, он блокирует мьютекс в своем конструкторе и разблокирует его в своем деструкторе.

Вот что происходит, когда вы запускаете этот код:

$ clang++ -std=c++17 -o RaiiMutex RaiiMutex.cpp && ./RaiiMutex
Locking mutex with scoped_lock…
Mutex is locked!
Throwing exception
Caught exception
Locking mutex with scoped_lock…
Mutex is locked!

Больше не зависает! Это потому, что std::scoped_lock является классом RAII и снимает блокировку в своем деструкторе. В результате std::scoped_lock устойчив к исключениям и раннему возврату (т.е. он будет разблокирован в этих ситуациях).

Хороший пример Mutex # 2

В этом примере мы реализуем простую версию std::scoped_lock, чтобы лучше понять, что происходит.

Вот что происходит, когда мы запускаем код:

$ clang++ -std=c++17 -o RaiiMutex RaiiMutex.cpp && ./RaiiMutex
CustomScopedLock locking mutex…
CustomScopedLock has locked mutex!
Throwing exception
CustomScopedLock has unlocked mutex
Caught exception
CustomScopedLock locking mutex…
CustomScopedLock has locked mutex!
CustomScopedLock has unlocked mutex

Обратите внимание, что после создания исключения CustomScopedLock разблокирует мьютекс в его деструкторе. Это именно то, что делал std::scoped_lock, но теперь наши логи стали немного понятнее, и мы реализовали это сами!

Разные примеры

Если вы хотите, чтобы ваши потоки автоматически объединялись в зависимости от времени жизни объекта, вы можете написать простой класс RAII, подобный этому:

Когда выйдет C ++ 20, std::jthread избавит от необходимости в подобных ручных решениях.

Что касается указателей, вы должны никогда не писать новые - всегда используйте умный указатель, т.е. всегда используйте либо std::make_unique, либо std::make_shared. Можно передавать указатели - например, нормально, когда функции C ++ принимают указатели в качестве аргументов, - но память должна быть привязана к времени жизни объекта некоторого интеллектуального указателя. Это также означает, что вы должны никогда не записывать удаление, потому что память будет освобождена, когда умный указатель будет уничтожен. Конечно, из этих правил есть исключения, но они должны быть редкими.

Я не собираюсь перечислять все существующие классы RAII (std::vector вместо массивов, std::string вместо массивов символов и т. Д.), Но я не думаю, что мне это нужно. К настоящему времени вы должны понять суть. Если вы пишете строфы для очистки - если вы пишете _27 _ / _ 28 _ / _ 29_ / и т. Д. - вам следует хорошо подумать о том, что вы делаете. Вместо этого обычно можно использовать класс RAII.