Что такое глубокое копирование
Использование функций copy и deepcopy в Python
В этой статье я расскажу, как скопировать объект с помощью мелкой копии и глубокой копии в Python.
Введение
При программировании нам нужно скопировать существующие данные. Когда мы присваиваем переменную другому оператору, то оператор присваивания не копирует объект, а просто создает новую ссылку на тот же объект.
Давайте приступим к рассмотрению конкретных примеров.
Как использовать функцию copy в Python?
Мелкая копия — это функция, используемая для создания копии существующего объекта коллекции. Когда мы пытаемся скопировать объект коллекции с помощью неглубокой копии, эта функция создает новый объект коллекции и сохраняет ссылки на элементы исходного объекта.
Мы можем использовать неглубокое копирование в Python с помощью модуля копирования. Для выполнения операции неглубокого копирования мы используем метод copy модуля copy.
Метод copy принимает исходный объект коллекции в качестве входных данных и создает новый объект коллекции со ссылкой на элементы исходного объекта коллекции. Затем он возвращает ссылку на новый объект коллекции.
В следующем примере я создам копию данного словаря Python с помощью метода copy:
В выходных данных мы видим, что мы создали такой же словарь, как и исходный словарь, приведенный выше.
Как работает неглубокое копирование?
В неглубокой копии, когда создается новый объект, он имеет ссылки на элементы исходного объекта. Если мы попытаемся внести какие-либо изменения во вновь созданный объект, он не будет отражен в исходном объекте, учитывая, что элементы в объектах не должны ссылаться на другой объект, то есть не должно быть никакой вложенности.
Если мы внесем какие-либо изменения в исходный объект, это также не отразится на копии исходного объекта, учитывая, что вложенность не должна быть выполнена.
Точно так же, когда мы добавляем какой-либо элемент к исходному объекту, он не будет иметь никакого влияния на новый объект.
Сценарии, рассмотренные выше, изменяются, когда в объектах присутствует вложенность, то есть когда копируемые объекты содержат другие объекты, изменения, происходящие во вложенных объектах, видны как в исходном, так и в копируемом объекте.
Это можно увидеть в следующем примере:
При копировании объекта с помощью метода copy.copy создается только копия объекта, передаваемая в качестве параметра методу copy. Элементы внутри объекта не копируются, копируются только ссылки на элементы.
Таким образом, когда в исходном объекте в качестве элементов присутствуют только примитивные типы данных, такие как int, double, string. Изменения не видны новому объекту при выполнении в исходном объекте, поскольку эти типы данных неизменяемы и для каждого изменения создается новый объект.
Но в случае вложенных объектов ссылки не изменяются, и когда мы вносим какие-либо изменения в один из объектов, они видны в другом объекте.
Как использовать deepcopy в Python?
Чтобы избежать проблемы, обсуждаемой при выполнении неглубокого копирования, мы будем использовать метод deepcopy. Метод deepcopy рекурсивно создает копию каждого элемента объекта и не копирует ссылки.
Это можно сделать следующим образом:
После использования функции deepcopy изменения, внесенные в исходный объект, не будут отображаться в скопированном объекте, даже если присутствует вложенность.
Здесь мы видим, что в отличие от copy, когда мы копируем объект с помощью deepcopy, изменения, внесенные в исходный объект, не влияют на скопированный объект и наоборот, потому что объект, созданный методом deepcopy, не содержит ссылок на элементы исходного словаря, в то время как в случае метода copy вновь созданные объекты содержат ссылки на элементы исходного объекта.
Заключение
В этой статье вы узнали о неглубоком копировании и глубоком копировании в Python.
Вы видели, что при наличии вложенных объектов для создания копии объектов следует использовать функцию deepcopy. Мы также можем написать программы, используемые в этой статье, с обработкой исключений с помощью python try, чтобы сделать программы более надежными или чтобы систематически обрабатывать ошибки.
Урок №145. Поверхностное и глубокое копирование
Обновл. 13 Сен 2021 |
На этом уроке мы рассмотрим поверхностное и глубокое копирование в языке C++.
Поверхностное копирование
Поскольку язык C++ не может знать наперед всё о вашем классе, то конструктор копирования и оператор присваивания, которые C++ предоставляет по умолчанию, используют почленный метод копирования — поверхностное копирование. Это означает, что C++ выполняет копирование для каждого члена класса индивидуально (используя оператор присваивания по умолчанию вместо перегрузки оператора присваивания и прямую инициализацию вместо конструктора копирования). Когда классы простые (например, в них нет членов с динамически выделенной памятью), то никаких проблем с этим не должно возникать.
Рассмотрим следующий класс Drob:
Конструктор копирования и оператор присваивания по умолчанию, предоставляемые компилятором автоматически, выглядят примерно следующим образом:
Поскольку эти конструктор копирования и оператор присваивания по умолчанию отлично подходят для выполнения копирования с объектами этого класса, то действительно нет никакого смысла писать здесь свои собственные версии конструктора копирования и перегрузки оператора.
Однако при работе с классами, в которых динамически выделяется память, почленное (поверхностное) копирование может вызывать проблемы! Это связано с тем, что при поверхностном копировании указателя копируется только адрес указателя — никаких действий по содержимому адреса указателя не предпринимается. Например:
Вышеприведенный класс — это обычный строковый класс, в котором выделяется память для хранения передаваемой строки. Здесь мы не определяли конструктор копирования или перегрузку оператора присваивания. Следовательно, язык C++ предоставит конструктор копирования и оператор присваивания по умолчанию, которые будут выполнять поверхностное копирование. Конструктор копирования выглядит примерно следующим образом:
Хотя этот код выглядит достаточно безвредным, но он имеет в себе коварную проблему, которая приведет к сбою программы! Можете найти эту проблему? Если нет, то ничего страшного.
Разберем этот код по строкам:
Корнем этой проблемы является поверхностное копирование, выполняемое конструктором копирования по умолчанию. Такое копирование почти всегда приводит к проблемам.
Глубокое копирование
Одним из решений этой проблемы является выполнение глубокого копирования. При глубоком копировании память сначала выделяется для копирования адреса, который содержит исходный указатель, а затем для копирования фактического значения. Таким образом копия находится в отдельной, от исходного значения, памяти и они никак не влияют друг на друга. Для выполнения глубокого копирования нам необходимо написать свой собственный конструктор копирования и перегрузку оператора присваивания.
Рассмотрим это на примере с классом SomeString:
Как вы видите, реализация здесь более углубленная, нежели при поверхностном копировании! Во-первых, мы должны проверить, имеет ли исходный объект ненулевое значение вообще (строка №8). Если имеет, то мы выделяем достаточно памяти для хранения копии этого значения (строка №11). Наконец, копируем значение-строку (строки №14-15).
Теперь рассмотрим перегрузку оператора присваивания:
Заметили, что код перегрузки очень похож на код конструктора копирования? Но здесь есть 3 основных отличия:
Мы добавили проверку на самоприсваивание.
Мы возвращаем текущий объект (с помощью указателя *this), чтобы иметь возможность выполнить цепочку операций присваивания.
Мы явно удаляем любое значение, которое объект уже хранит (чтобы не произошло утечки памяти).
При вызове перегруженного оператора присваивания, объект, которому присваивается другой объект, может содержать предыдущее значение, которое нам необходимо очистить/удалить, прежде чем мы выделим память для нового значения. С не динамически выделенными переменными (которые имеют фиксированный размер) нам не нужно беспокоиться, поскольку новое значение просто перезапишет старое. Однако с динамически выделенными переменными нам нужно явно освободить любую старую память до того, как мы выделим любую новую память. Если мы этого не сделаем, сбоя не будет, но произойдет утечка памяти, которая будет съедать нашу свободную память каждый раз, когда мы будем выполнять операцию присваивания!
Лучшее решение
В Стандартной библиотеке C++ классы, которые работают с динамически выделенной памятью, такие как std::string и std::vector, имеют свое собственное управление памятью и свои конструкторы копирования и перегрузку операторов присваивания, которые выполняют корректное глубокое копирование. Поэтому, вместо написания своих собственных конструкторов копирования и перегрузки оператора присваивания, вы можете выполнять инициализацию или присваивание строк, или векторов, как обычных переменных фундаментальных типов данных! Это гораздо проще, менее подвержено ошибкам, и вам не нужно тратить время на написание лишнего кода!
Заключение
Конструктор копирования и оператор присваивания, предоставляемые по умолчанию языком C++, выполняют поверхностное копирование, что отлично подходит для классов без динамически выделенных членов.
Классы с динамически выделенными членами должны иметь конструктор копирования и перегрузку оператора присваивания, которые выполняют глубокое копирование.
Используйте функциональность классов из Стандартной библиотеки C++, нежели самостоятельно выполняйте/реализовывайте управление памятью.
Поделиться в социальных сетях:
Что такое глубокое копирование
А теперь попробуем изменить содержимое списка sublist и посмотрим, что случится:
Иногда такое поведение нам не требуется: мы хотим, чтобы объекты были абсолютно независимыми. Но об этом чуть позже, а сейчас рассмотрим о других способах создания поверхностных копий.
Как ещё можно создавать поверхностные копии
Работая со списками, часто приходится выполнять срезы (slicing, о котором мы говорили тут). Для создания поверхностной копии можно использовать данную технику:
Или пример создания списка списка:
— который приводит к тому же результату. То же самое можно применить и к словарю:
Глубокое копирование в Python
Если вам требуется копирование такие копии, внутренние объекты которых не связаны с чем-то внешним, то вам понадобится глубокое копирование.
Глубокое копирование похоже на рекурсивный алгоритм: копируются элементы первого уровня, и если был найден изменяемый объект, то заходим в глубь этого объекта и копируем его содержимое. Ниже приведен пример того, как это можно реализовать.
Мы теперь можем воспользоваться этой функцией для копирования списка:
Как можно увидеть, изменение списка sublist не повлекло за собой изменение копии.
Для больших списков такая функция не будет работать с приемлемой скоростью, но к счастью в Python есть модуль, который реализуется и поверхностное, и глубокое копирование.
Модуль copy и функция deepcopy
Стандартный Python-модуль copy имеет две нужные нам функции [2]:
Функция deepcopy достаточно умна, чтобы справляться с возникающими трудностями, которые могут возникнуть при копировании. Можете даже взглунять на количество проверок в исходном коде.
О том, как происходит копирование данных большого размера (Big Data) вы узнаете на наших образовательных курсах в лицензированном учебном центре обучения и повышения квалификации руководителей и ИТ-специалистов (менеджеров, архитекторов, инженеров, администраторов, Data Scientist’ов и аналитиков Big Data) в Москве:
Глубокое и поверхностное копирование в Python
Введение
В этом уроке мы собираемся обсудить поверхностное (shallow) и глубокое (deep) копирование с помощью примеров на Python. Мы рассмотрим определение глубокого и поверхностного копирования, а также их реализации на языке Python, чтобы оценить основные различия между двумя типами копирования.
Во многих программах, которые мы пишем, нам часто приходится копировать объекты по какой либо причине, например, из-за улучшения вычислительной эффективности. Есть два способа сделать это: сделать полную копию или поверхностную копию. Прежде чем мы обсудим различия между ними, давайте сначала рассмотрим, что такое глубокое и поверхностное копирование.
Глубокое копирование в Python
Глубокая копия создает новую и отдельную копию всего объекта или списка со своим уникальным адресом памяти. Это означает, что любые изменения, внесенные вами в новую копию объекта или списка, не будут отражаться в исходной. Этот процесс происходит следующим образом, сначала создается новый список или объект, а затем рекурсивно копируя все элементы из исходного в новый.
Короче говоря, оба объекта становятся полностью независимы друг от друга. Это похоже на концепцию передачи по значению в таких языках, как C ++, Java и C #.
Пример глубокого копирования
Для реализации концепции глубокого копирования в Python мы будем использовать модуль copy.
Допустим, у нас есть список с именем result_A, который содержит оценки ученика A по 3 предметам, и мы хотим создать точно такой же список результатов и для ученика B. Мы сделаем глубокую копию списка result_A и позже сделаем несколько изменений в этой копии, чтобы посмотреть на оценки ученика B.
Пример 1:
В приведенном выше сценарии мы используем метод deepcopy из модуля copy, чтобы скопировать список result_A в result_B. Далее мы печатаем содержимое обоих списков на экране.
Как видите, списки идентичны. Позже в этой статье мы рассмотрим, как они отличается от результата поверхностного копирования.
Поверхностное копирование в Python
Поверхностное копирование также создает отдельный новый объект или список, но вместо копирования дочерних элементов в новый объект, оно просто копирует ссылки на их адреса памяти. Следовательно, если вы сделаете изменение в исходном объекте, оно будет отражено в скопированном объекте, и наоборот. Короче говоря, обе копии зависят друг от друга. Это похоже на концепцию передачи по ссылке в таких языках программирования, как C ++, C # и Java.
Пример поверхностного копирования
Чтобы реализовать это в Python, мы снова будем использовать модуль copy, но на этот раз мы будем использовать функцию copy.
Давайте воспользуемся тем же списком.
Пример 2:
В приведенном выше сценарии мы используем метод copy из модуля copy, чтобы сделать поверхностную копию списка result_A, который мы назвали result_B. Далее содержимое обоих списков было напечатано на консоли.
Опять же, такие же списки, как и ожидалось. Далее мы объясним разницу между результатами, которые мы получаем от функций copy и deepcopy.
Разница между глубоким и поверхностным копированием
Теперь, когда мы обсудили, что такое поверхностное и глубокое копирование, пришло время поговорить о разнице между ними. По сути, есть только два основных различия, и они связаны друг с другом:
Прежде чем мы перейдем к примеру в котором будет рассмотрена разница, я бы хотел, чтобы вы представили аналогию для сценария примера. Допустим, два человека хотят поделиться напитком; у них два пустых стакана и две соломинки. Они могут поделиться напитком двумя способами:
Первый сценарий — сценарий это аналогия поверхностного копирования. Обе переменные/экземпляры используют одну и ту же ячейку памяти для своих операций. Второй сценарий — сценарий глубокого копирования. Обе переменные/экземпляры используют две разные области памяти для своих операций.
Пример сравнения
Чтобы прояснить разницу, давайте использовать эту информацию в наших двух приведенных выше примерах, начиная с примера 1.
Выше мы создали список result_A и сделали его полную копию с именем result_B. Давайте попробуем изменить содержимое в result_B и посмотрим, повлияет ли это на содержимое result_A.
Ожидаемый результат заключается в том, что первоначальный список остается без изменений. И, как вы видите, изменения в глубокой копии не повлияли на исходный список.
Теперь давайте попробуем то же самое с примером 2 — поверхностное копирование.
Здесь ожидаемый результат состоит в том, что как исходный список, так и скопированный список изменяются. И, как вы можете видеть, внесение изменений в поверхностную копию привело к тому, что эти изменения также были отражены в исходном списке.
Заключение
В этом посте мы говорили о том, что такое поверхностное и глубокое копирование и как они реализуются на языке Python, используя модуль «copy». Мы использовали две его функции, а именно: copy и deepcopy. Кроме того, мы обсудили два основных различия между поверхностной и глубокой копией, а также реализовали поверхностное и глубокое копирование в python, чтобы лучше понять эти различия.
Семантика копирования и управление ресурсами в C++
В C++ программист должен сам принимать решения о том, как будут освобождаться используемые ресурсы, автоматических средств типа сборщика мусора нет. В статье рассмотрены возможные варианты решения этой задачи, детально рассмотрены потенциальные проблемы, а также ряд сопутствующих вопросов.
Оглавление
Введение
Объектно-ориентированные возможности C++ естественно приводит к следующему решению: класс, управляющий ресурсом, содержит дескриптор ресурса в качестве члена, инициализирует дескриптор при захвате ресурса и освобождает ресурс в деструкторе. Но после некоторых размышлений (или опыта) приходит понимание того, что не все так просто. И главной проблемой является семантика копирования. Если класс, управляющий ресурсом, использует копирующий конструктор, сгенерированный компилятором по умолчанию, то после копирования объекта мы получим две копии дескриптора одного и того же ресурса. Если один объект освобождает ресурс, то после этого второй сможет совершить попытку использования или освобождения уже освобожденного ресурса, что в любом случае является не корректным и может привести к так называемому неопределенному поведению, то есть может произойти все, что угодно, например аварийное завершение программы.
К счастью, в C++ программист может полностью контролировать процесс копирования путем собственного определения копирующего конструктора и оператора копирующего присваивания, что позволяет решить вышеописанную проблему, причем обычно не одним способом. Реализация копирования должна быть тесно увязана с механизмом освобождения ресурса, и это все вместе будем называть стратегией копирования-владения. Хорошо известно так называемое «правило большой тройки», которое утверждает, что если программист определил хотя бы одну из трех операций — копирующий конструктор, оператор копирующего присваивания или деструктор, — то он должен определить все три операции. Стратегии копирования-владения как раз и конкретизируют, как это надо делать. Существует четыре основных стратегии копирования-владения.
1. Основные стратегии копирования-владения
1.1. Стратегия запрета копирования
Это самая простая стратегия. В этом случае просто запрещено копировать и присваивать экземпляры класса. Деструктор освобождает захваченный ресурс. В C++ запретить копирование не сложно, класс должен объявить, но не определять закрытые копирующий конструктор и оператор копирующего присваивания.
Попытки копирования пресекаются компилятором и компоновщиком.
Стандарт C++11 предлагает для этого случая специальный синтаксис:
Этот синтаксис более нагляден и дает более понятные сообщения компилятора при попытке копирования.
1.2. Стратегия исключительного владения
В этом случае при реализации копирования и присваивания дескриптор ресурса перемещается от объекта-источника к целевому объекту, то есть остается в единственном экземпляре. После копирования или присваивания, объект-источник имеет нулевой дескриптор и не может использовать ресурс. Деструктор освобождает захваченный ресурс. Для этой стратегии также используются термины эксклюзивное или строгое владение [Josuttis], Андрей Александреску [Alexandrescu] использует термин разрушающее копирование. В C++11 это делается следующим образом: запрещается обычное копирование и копирующее присваивание вышеописанным способом, и реализуются семантики перемещения, то есть определяются перемещающий конструктор и оператор перемещающего присваивания. (Подробнее о семантике перемещения далее.)
Таким образом, стратегию исключительного владения можно считать расширением стратегии запрета копирования.
1.3. Стратегия глубокого копирования
В этом случае можно копировать и присваивать экземпляры класса. Необходимо определить копирующий конструктор и оператор копирующего присваивания, так, чтобы целевой объект копировал к себе ресурс из объекта-источника. После этого каждый объект владеет своей копией ресурса, может независимо использовать, модифицировать и освобождать ресурс. Деструктор освобождает захваченный ресурс. Иногда для объектов, использующих стратегию глубокого копирования, применяют термин объекты-значения.
Эта стратегия применима не ко всем ресурсам. Ее можно применять к ресурсам, связанным с буфером памяти, например строкам, но не очень понятно, как ее применять к объектам ядра ОС типа файлов, мьютексов и т.д.
Стратегия глубокого копирования используется во всех типах объектных строк, std::vector<> и других контейнерах стандартной библиотеки.
1.4. Стратегия совместного владения
В этом случае можно копировать и присваивать экземпляры класса. Необходимо определить копирующий конструктор и оператор копирующего присваивания, в которых копируется дескриптор ресурса (а также другие данные), но не сам ресурс. После этого каждый объект имеет свою копию дескриптора, может использовать, модифицировать, но не может освобождать ресурс, пока есть хотя бы еще один объект, владеющий копией дескриптора. Ресурс освобождается после того, как последний объект, владеющий копией дескриптора, выходит из области видимости. Как это может быть реализовано, описано ниже.
2. Стратегия глубокого копирования — проблемы и решения
Рассмотрим шаблон функции обмена состояниями объектов типа T в стандартной библиотеке C++98.
Если тип T владеет ресурсом и использует стратегию глубокого копирования, то мы имеем три операции выделения нового ресурса, три операции копирования и три операции освобождения ресурсов. Тогда как в большинстве случаев эту операцию можно осуществить вообще без выделения новых ресурсов и копирования, достаточно объектам обменяться внутренними данными, включая дескриптор ресурса. Подобных примеров, когда приходится создавать временные копии ресурса и тут же их освобождать, можно привести много. Столь неэффективная реализация повседневных операций стимулировала поиск решений для их оптимизации. Рассмотрим основные варианты.
2.1. Копирование при записи
2.2. Определение функции обмена состояниями для класса
1. Определить в классе функцию-член Swap() (имя не принципиально), реализующую обмен состояниями.
2. В том же пространстве имен, что и класс X (обычно в том же заголовочном файле), определить свободную (не-член) функцию swap() следующим образом (имя и сигнатура принципиальны):
В стандартной библиотеке C++ все контейнеры, интеллектуальные указатели, а также другие классы реализуют функцию обмена состояниями описанным выше способом.
Функция-член Swap() определяется обычно легко: необходимо последовательно применять к базам и членам операцию обмена состояниями, если они ее поддерживают, и std::swap() в противном случае.
Приведенное описание несколько упрощено, более детальное можно найти в [Meyers2]. Обсуждение проблем, связанных с функцией обмена состояниями, также можно найти в [Sutter/Alexandrescu].
Функцию обмена состояниями можно отнести к одной из базовых операций класса. С помощью нее можно изящно определить другие операции. Например, оператор копирующего присваивания определяется через копирование и Swap() следующим образом:
Этот шаблон называется идиомой «копирование и обмен» или идиомой Герба Саттера, подробнее см. [Sutter], [Sutter/Alexandrescu], [Meyers2]. Его модификацию можно применить для реализации семантики перемещения, см. разделы 2.4, 2.6.1.
2.3. Удаление промежуточных копий компилятором
Компиляторы могут удалять промежуточные копии и в других ситуациях.
2.4. Реализация семантики перемещения
Реализация семантики перемещения заключается в определении перемещающего конструктора, имеющего параметр типа rvalue-ссылка на источник и оператора перемещающего присваивания с таким же параметром.
В стандартной библиотеке C++11 шаблон функции обмена состояниями определен следующим образом:
В соответствии с правилами разрешения перегрузок функций, имеющих параметры типа rvalue-ссылка (см. Приложение А), в случае, когда тип T имеет перемещающий конструктор и оператор перемещающего присваивания, будут использоваться они, и выделения временных ресурсов и копирования при этом не будет. В противном случае будут использованы копирующий конструктор и оператор копирующего присваивания.
Операции реализуются путем последовательного применения операции перемещения к базам и членам класса, если они поддерживают перемещение, и операции копирования в противном случае. Понятно, что такой вариант далеко не всегда приемлем. Сырые дескрипторы не перемещаются, но копировать их обычно нельзя. При выполнении определенных условий компилятор может самостоятельно сгенерировать подобный перемещающий конструктор и оператор перемещающего присваивания, но этой возможностью лучше не пользоваться, условия эти довольно запутаны и легко могут измениться при доработке класса. Подробнее см. [Meyers3].
Вообще реализация и использование семантики перемещения довольно «тонкая штучка». Компилятор может применить копирование там, где программист ожидает перемещение. Приведем несколько правил, позволяющих исключить или хотя бы снизить вероятность такой ситуации.
Правило 2 обсуждалось выше. Правило 4 связано с тем, что именованные rvalue-ссылки являются lvalue (см. также Приложение А). Это можно проиллюстрировать на примере определения перемещающего конструктора.
Другой пример этого правила приведен выше, при определении оператора перемещающего присваивания. Реализация семантики перемещения рассматривается также в разделе 6.2.1.
2.5. Размещение vs. вставки
Размещение имеет следующие преимущества:
Приведем пример, где одна и та же задача решается разными способами.
2.6. Итоги
Одной из главных проблем классов, реализующих стратегию глубокого копирования, является создание временных копий ресурса. Ни один из описанных способов полностью не решает эту проблему и полностью не замещает какой-то другой способ. В любом случае программист должен распознавать подобные ситуации и писать правильный код с учетом описанной проблемы и возможностей языка. Простейший пример — это передача параметров в функцию: передавать надо по ссылке, а не по значению. Эта ошибка не распознается компилятором, но при этом происходит либо ненужное копирование, либо программа работает не так, как задумано. Другой пример связан с использованием перемещения: программист должен четко соблюдать условия, при которых компилятор выбирает перемещение, иначе «молча» будет использовано копирование.
Если все-таки при реализации класса-владельца ресурса принято решение использовать стратегию глубокого копирования, то кроме реализации семантики копирования можно рекомендовать следующие шаги:
3. Возможные варианты реализации стратегии совместного владения
В стандартной библиотеке C++11 интеллектуальный указатель std::shared_ptr<> также использует счетчик ссылок. Но объект, контролируемый этим интеллектуальным указателем, может не иметь внутреннего счетчика ссылок, поэтому создается специальный скрытый объект, называемый управляющим блоком, который управляет счетчиком ссылок. Понятно, что это является дополнительным накладным расходом. Интеллектуальный указатель std::shared_ptr<> подробно описан в [Josuttis], [Meyers3].
Андрей Александреску рассматривает реализацию стратегии совместного владения с помощью двусвязного списка объектов-владельцев [Alexandrescu]. Герберт Шилдт описывает (и приводит полный код) реализации, основанной на комбинации двусвязного списка и счетчика ссылок [Schildt]. Реализации на основе двусвязного списка также не могут освободить ресурсы, имеющие циклические ссылки.
Описание более сложных схем удаления неиспользуемых объектов (сборщиков мусора) можно найти в [Alger].
Реализация стратегии совместного владения также должна учитывать возможность многопоточного доступа к объектам-владельцам. Эта тема обсуждается в [Josuttis] и [Alexandrescu].
4. Стратегия исключительного владения и семантика перемещения
5. Стратегия запрета копирования — быстрое начало
На первый взгляд стратегия запрета копирования сильно ограничивает программиста. Но в реальности оказывается, что очень многие объекты не нуждаются в копировании. Поэтому, при проектировании класса, управляющего ресурсами, в качестве начального решения можно рекомендовать выбор стратегии запрета копирования. Если потребность в копировании возникает, то компилятор сразу это обнаружит, после чего можно проанализировать, для чего нужно копирование (и нужно ли оно вообще) и сделать необходимые доработки. В ряде случаев, например, при передаче по стеку вызовов, можно использовать ссылку. При необходимости хранить объекты в контейнерах стандартной библиотеки можно использовать указатели (лучше интеллектуальные) на объекты, созданные в динамической памяти. Вообще использование динамической памяти и интеллектуальных указателей является достаточно универсальным вариантом, который может помочь и в других случаях. Более сложный вариант — реализация семантики перемещения. Детали обсуждаются в разделе 6.
Небрежный программный дизайн, при котором не реализовывается какая-либо стратегия копирования-владения, зачастую «сходит с рук», потому что объекты-владельцы ресурса в действительности не копируются. В этом случае запрет копирования или другая стратегия копирования-владения внешне ничего не меняет. Но, тем не менее, это все равно надо делать, надо всегда писать правильный код, даже если в каком-то контексте неправильный код не проявляет своих дефектов. Неправильный код рано или поздно «выстрелит».
6. Жизненный цикл ресурса и объекта-владельца ресурса
Во многих случаях важно понимать, как соотносится жизненный цикл ресурса и объекта-владельца ресурса. Естественно, что это тесно связано со стратегией копирования-владения. Рассмотрим несколько вариантов.
6.1. Захват ресурса при инициализации
В простейшем случае жизненный цикл ресурса и объекта-владельца ресурса совпадают. То есть для класса, управляющего ресурсом, выполняются условия:
Конструкторы таких классов обычно имеют параметры, необходимые для захвата ресурса и, соответственно, конструктор по умолчанию отсутствует. В стандартной библиотеке C++11 таким образом реализованы некоторые классы для поддержки многопоточной синхронизации.
Эта схема управления ресурсом является одним из вариантов идиомы «захват ресурса при инициализации» (resource acquisition is initialization, RAII). Идиома RAII широко обсуждается во многих книгах и в интернете (и часто трактуется немного по разному или просто не вполне четко), см., например [Dewhurst1]. Приведенный выше вариант можно назвать «строгим» RAII. В таком классе дескриптор ресурса естественно сделать константным членом, и, соответственно, можно использовать термин неизменяемое (immutable) RAII.
6.2. Расширенные варианты управления жизненным циклом ресурса
Класс, реализованный в соответствии с идиомой RAII, идеален для создания простых, короткоживущих объектов, время жизни которых блок. Но если объект должен быть членом другого класса или быть элементом массива или какого-нибудь контейнера, отсутствие конструктора по умолчанию, а также семантики копирования-перемещения может создать много проблем для программиста. Кроме того, иногда захват ресурса происходит в несколько шагов, причем их число может быть заранее неизвестно, что крайне затрудняет реализацию захвата ресурса в конструкторе. Рассмотрим возможные варианты решения этой проблемы.
6.2.1. Расширенный жизненный цикл ресурса
Будем говорить, что класс, управляющий ресурсом, поддерживает расширенный жизненный цикл ресурса, если для него выполнены следующие условия:
Класс, реализованный в соответствии с идиомой RAII, можно доработать по стандартному шаблону, так, что он будет поддерживать расширенный жизненный цикл ресурса. Для этого надо дополнительно определить конструктор по умолчанию, перемещающий конструктор и оператор перемещающего присваивания.
После этого расширенный жизненный цикл ресурса реализуется совсем просто.
Как было показано в разделе 2.4, стандартный способ определения перемещающего конструктора и оператора перемещающего присваивания использует функцию-член обмена состояниями. Кроме того, функция-член обмена состояниями позволяет очень легко определить отдельные функции-члены захвата и освобождения ресурса. Вот соответствующий новый вариант.
Определение перемещающего конструктора и оператора перемещающего присваивания:
Определение отдельных функций-членов захвата и освобождения ресурса:
Следует обратить внимание, что в описанном шаблоне захват ресурса всегда происходит в конструкторе, а освобождение в деструкторе, функция-член обмена состояниями играет чисто техническую роль. Это упрощает и делает более надежным кодирование захвата и освобождения ресурса, так как, компилятор берет на себя часть логики реализации, особенно в деструкторе. В деструкторе компилятор обеспечивает вызов деструкторов для членов и баз в порядке обратном вызову конструкторов, что почти всегда гарантирует отсутствие ссылок на удаленные объекты.
В приведенных выше примерах определения оператора копирующего присваивания и функции-члена захвата ресурса использовалась идиома «копирование и обмен», в соответствии с которой сначала захватывается новый ресурс, потом освобождается старый. Эта схема обеспечивает так называемую строгую гарантию безопасности исключений: если при захвате ресурса произошло исключение, то объект останется в том же состоянии, что и до начала операции (транзакционная семантика). В определенных ситуациях может оказаться более предпочтительной другая схема: сначала освобождается старый ресурс, затем захватывается новый. Такой вариант обеспечивает более слабую гарантию безопасности исключений, называемую базовой: если при захвате ресурса произошло исключение, то объект уже не обязательно останется в том же состоянии, но новое состояние будет корректным. Кроме того, при определении оператора копирующего присваивания по этой схеме необходима проверка на самоприсваивание. Подробнее гарантии безопасности исключений обсуждаются в [Sutter], [Sutter/Alexandrescu], [Meyers2].
Итак, переход от RAII к расширенному жизненному циклу ресурса очень похож на переход от стратегии запрета копирования к стратегии исключительного владения.
6.2.2. Однократный захват ресурса
Этот вариант можно рассматривать как промежуточный между RAII и расширенным жизненным циклом ресурса. Будем говорить, что класс, управляющий ресурсом, использует однократный захват ресурса, если для него выполнены следующие условия:
Это «почти» RAII, единственное отличие — это возможность формального разделения операции создания объекта и захвата ресурса. Такой класс может иметь перемещающий конструктор, но не оператор перемещающего присваивания, иначе нарушится условие п. 3. Это упрощает хранение объектов в стандартных контейнерах. Несмотря на некоторую «недоделанность», данный вариант достаточно практичен.
6.2.3. Повышение уровня косвенности
Другой подход к расширению жизненного цикла ресурса — это повышение уровня косвенности. В этом случае сам объект RAII рассматривается как ресурс, а указатель на него будет дескриптором ресурса. Захват ресурса сводится к созданию объекта в динамической памяти, а освобождение к его удалению. В качестве класса, управляющим таким ресурсом, можно использовать один из интеллектуальных указателей стандартной библиотеки или аналогичный по функционалу класс (подобные классы называют классами-дескрипторами). Стратегия копирования-владения определяется интеллектуальным указателем или легко реализуется (для класса-дескриптора). Этот способ значительно проще описанного в разделе 6.2.1, единственный недостаток заключается в более интенсивном использовании динамической памяти.
6.3. Совместное владение
При использовании стратегии совместного владения, объект-владелец ресурса может быть жестко привязан к ресурсу по схеме RAII, или использовать более гибкую схему: многократно захватывать ресурс и разрывать связь с ресурсом на протяжении своей жизни. В любом случае ресурс будет жить, пока есть хоть один объект, связанный с ресурсом.
7. Итоги
Класс, управляющий ресурсом, не должен иметь копирующий конструктор, оператор копирующего присваивания и деструктор, сгенерированные компилятором по умолчанию. Эти функции-члены должны быть определены в соответствии со стратегией копирования-владения.
Существует 4 основные стратегии копирования-владения:
Функцию обмена состояниями следует отнести к базовым операциям класса. Она используется в алгоритмах стандартной библиотеки, а также для определения других функций-членов класса: оператора копирующего присваивания, перемещающего конструктора и оператора перемещающего присваивания, функций-членов захвата и освобождения ресурса.
Определение перемещающего конструктора и оператора перемещающего присваивания позволяет оптимизировать работу с классами, использующими стратегию глубокого копирования. Для классов, использующих стратегию запрета копирования, это позволяет расширить стратегию копирования-владения, реализовать более гибкую схему управления жизненным циклом ресурса, упростить размещение объектов в контейнерах.
Приложения
Приложение А. Rvalue-ссылки
Для примеров будем использовать класс:
Как и обычные ссылки, rvalue-ссылки необходимо инициализировать.
Первым отличием rvalue-ссылок от обычных С++ ссылок заключается в том, что их нельзя инициализировать с помощью lvalue. Пример:
Для корректной инициализации необходимо использовать rvalue:
или lvalue должно быть явно приведено к типу rvalue-ссылки:
Rvalue ссылки можно инициализировать с помощью rvalue встроенного типа, для обычных ссылок это запрещено.
После инициализации rvalue-ссылки можно использовать как обычные ссылки.
Rvalue-ссылки неявно приводятся к обычным ссылкам.
Rvalue-ссылки редко используются как самостоятельные переменные, обычно они используются как параметры функций. В соответствии с правилами инициализации, если функция имеет параметры типа rvalue-ссылок, то ее можно вызвать только для rvalue аргументов.
Если имеются несколько перегруженных функций, то при разрешении перегрузки для rvalue аргумента версия с параметром типа rvalue-ссылка имеет приоритет над версией с параметром типа обычная ссылка или обычная ссылка на константу, хотя последние и могут быть допустимыми вариантами. И это правило является второй ключевой особенностью rvalue-ссылок.
Функция с параметром, передаваемым по значению, и перегруженная версия, имеющая параметр типа rvalue-ссылка, будут неразрешимы (ambiguous) для rvalue аргументов.
Для примера рассмотрим перегруженные функции
и несколько вариантов их вызова
Следует обратить внимание на один важный момент: именованная rvalue-ссылка сама по себе является lvalue.
Приложение Б. Семантика перемещения
Список литературы
[Alexandrescu]
Александреску, Андрей. Современное проектирование на C++.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2002.
[Guntheroth]
Гантерот, Курт. Оптимизация программ на C++. Проверенные методы для повышения производительности.: Пер. с англ. — СПб.: ООО «Альфа-книга», 2017.
[Josuttis]
Джосаттис, Николаи М. Стандартная библиотека C++: справочное руководство, 2-е изд.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2014.
[Dewhurst1]
Дьюхерст, Стивен С. C++. Священные знания, 2-е изд.: Пер. с англ. — СПб.: Символ-Плюс, 2013.
[Dewhurst2]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.
[Meyers1]
Мейерс, Скотт. Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов.: Пер. с англ. — М.: ДМК Пресс, 2000.
[Meyers2]
Мейерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.
[Meyers3]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендации по использованию C++11 и C ++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.
[Sutter]
Саттер, Герб. Решение сложных задач на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.
[Sutter/Alexandrescu]
Саттер, Герб. Александреску, Андрей. Стандарты программирования на С++.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2015.
[Schildt]
Шилдт, Герберт. Искусство программирования на C++.: Пер. с англ. — СПб.: БХВ-Петербург, 2005.
[Alger]
Элджер, Джефф. C++: библиотека программиста.: Пер. с англ. — СПб.: ЗАО «Издательство «Питер», 1999.