Конструкторы C ++, память и время жизни

Конструкторыc++памятьивремяжизни

C++ Initialization Hell

Что именно происходит, когда вы пишете Foo foo = new Foo () ; ? В одном утверждении содержится много всего, поэтому давайте попробуем разбить его на части. Во-первых, в этом примере выделяется новая память в куче, но чтобы понять все, что происходит, нам нужно будет объяснить, что означает объявление переменной в стеке . Если вы уже хорошо понимаете, как работает стек и как функции выполняют очистку перед возвратом, смело переходите к оператору new .

Время жизни стека

Описание стека очень часто игнорируется во многих других императивных языках, несмотря на то, что в этих языках он все еще есть (функциональные языки – это совершенно другой уровень странности). Начнем с очень простого:

  int foobar (int b) {int a;  а = б;  вернуть;  }  

Здесь мы объявляем функцию foobar , который принимает int и возвращает int . Первая строка функции объявляет переменную a типа int . Это все хорошо, но где целое число? . На большинстве современных платформ int преобразуется в 32-немного целое число, занимающее 4 байта пространства. Мы еще не выделили память, потому что не было нового оператора и нет был вызван malloc () . Где целое число?

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

Когда мы вызывали нашу функцию , параметр int b был помещен в стек. Параметры занимают память, поэтому они попадают в стек. Следовательно, прежде чем мы дойдем до оператора int a , в наш стек уже были помещены 4 байта памяти. Вот как выглядит наш стек в начале функции, если мы вызовем его с номером 100 (с прямым порядком байтов):

Stack for b

int a указывает компилятору вставить еще 4 байта памяти в стек, но у него нет начального значения, поэтому содержимое не определено:

Stack for a and b

a = b присваивает b переменной a , теперь наш стек выглядит так:

Наконец, return a сообщает компилятору оценить возвращаемое выражение (которое в нашем случае просто a , поэтому нечего оценивать), затем скопируйте результат в кусок память, которую мы зарезервировали ахе объявление времени для возвращаемого значения. Некоторые программисты могут предположить, что функция возвращает немедленно , как только return выполняется оператор – в конце концов, это то, что return значит, да? Однако в действительности функция все еще должна навести порядок, прежде чем она действительно сможет вернуться. В частности, нам нужно вернуть наш стек в состояние, в котором он был до вызова функции, удалив все, что мы поместили поверх него Stack for initialized a and b в обратном порядке . Итак, после копирования нашего возвращаемого значения a наша функция выталкивает верхнюю часть стека, что является последним, что мы толкнул. В нашем случае это int a , поэтому мы извлекаем его из стека. Теперь наш стек выглядит так:

Момент, с которого int a был помещен в стек в момент, когда он был извлечен из стека, называется временем жизни из int a . В этом случае int a имеет время жизни всей функции. После возврата из функции вызывающий объект должен вывести int b , параметр, с которым мы вызывали функцию. Теперь наш стек пуст, и время жизни Stack for initialized a and b из int b длиннее, чем продолжительность жизни of int a , потому что он был помещен первым (до вызова функции), а затем извлечен (после того, как функция вернулась). C ++ строит всю концепцию конструкторов и деструкторов на этой концепции времени жизни, и они могут быть очень сложными, но пока мы сосредоточимся только на времени жизни стека.

Давайте рассмотрим более сложный пример:

  int foobar (int b) {int a;  {int x;  х = 3;  {int z;  int max;  макс = 8004;  г = х + Ь;  if (z> max) {return z - max;  } х = х + z;  } // a = z;  // ОШИБКА КОМПИЛЯТОРА!  {int ten = 11;  а = х + десять;  }} вернуть;  }  

Давайте посмотрим на время жизни всех наших параметров и переменные в этой функции. Во-первых, перед вызовом функции мы помещаем int b в стек со значением того, что мы вызываем функция с – скажем, 903 . Затем мы вызываем функцию, которая немедленно помещает int a в стек. Затем мы вводим новый блок , используя символ {, который не потребляет память, а вместо этого действует как маркер для компилятора – мы увидим, для чего он используется позже. Затем мы помещаем int x в стек. Теперь у нас в стеке 3 целых числа. Мы устанавливаем int x в 3 , но int a все еще не определено. Затем мы вводим еще один новый блок . Пока ничего интересного не произошло. Затем мы нажимаем int z и int max в стек. Затем присваиваем 999 до int max и присвойте int z значение x + b – если мы прошли в 903 , это означает, что z теперь равно 903 , что меньше значения int max ( 8004 ), поэтому пропускаем оператор if на данный момент. Затем мы присваиваем x к x + z , который будет 906 .

Теперь все становится интересно. Наш самый верхний блок заканчивается с символом } . Это сообщает компилятору удалить все переменные, объявленные внутри этого блока . Мы поместили int z в стек внутри этого блока, так что теперь его нет. Мы больше не можем ссылаться на int z , и это будет ошибкой компилятора. int z считается, что вышел за рамки . Однако мы также поместили int max в стек и поместили его после int z . Это означает, что компилятор сначала извлечет int max из стека , и только потом будет затем он выталкивает int z из стека. Порядок, в котором это происходит, будет иметь решающее значение для понимания того, как время жизни работает с конструкторами и деструкторами, поэтому имейте это в виду.

Затем мы переходим в другую новую область видимости. Эта новая область все еще находится внутри первой созданной области, содержащей int x , поэтому мы все еще можем получить доступ к Икс. Мы определяем int ten и инициализируем его с помощью 11 . Затем мы устанавливаем int a равным x + ten , который будет 999 . Затем наша область видимости заканчивается, и int ten выходит за пределы области видимости и выталкивается из стека. Сразу после этого мы достигаем конца нашей первой области видимости, и int x извлекается из стека.

Наконец, мы достигаем return a , который копирует a в наш сегмент памяти возвращаемого значения, pops int a и возвращается к вызывающему абоненту, который затем выводит int b . Вот что происходит, когда мы передаем 903 , но что произойдет, если мы пройдем через 9000 ?

Все то же самое, пока мы не дойдем до оператора if , условие которого теперь выполняется, что приводит к преждевременному завершению функции и возврату z - макс . Что происходит со стеком?

Когда мы достигаем , возвращаем z - max компилятор оценивает оператор и копирует результат ( 8004) вне. Затем он начинает выталкивать все из стека (опять же, в обратном порядке). Последнее, что мы поместили в стек, было int max , поэтому оно извлекается первым. Затем выводится int z . Затем выводится int x . Затем выскакивает int a , функция возвращается и, наконец, int b появляется вызывающим абонентом. Такое поведение имеет решающее значение для того, как C ++ использует время жизни для реализации таких вещей, как интеллектуальные указатели и автоматическое управление памятью. На самом деле Rust использует похожую концепцию, но он использует ее гораздо больше, чем C ++.

новые Заявления

Хорошо, теперь мы знаем, как работает время жизни и где находятся переменные, когда они не выделены, но что происходит, когда вы выделять память? Что происходит с новым заявлением? Чтобы взглянуть на это, давайте воспользуемся упрощенным примером:

  int foo = new int ();   

Здесь мы разместили указатель на целое число на стек (который будет 8 байтов, если вы используете – битовая система) и присвоил результат new int () к нему. Что происходит, когда мы вызываем new int () ? В C ++ оператор new является расширением malloc () из C. Это означает, что он выделяет память из кучи , названный в честь структуры данных кучи . Когда вы выделяете память в куче, она никогда не выходит за рамки. Это то, с чем большинство программистов знакомо на других языках, за исключением того, что большинство других языков обрабатывают определение того, когда его освободить, а C ++ вынуждает вас удалить его самостоятельно. Память, выделенная в куче, просто есть, плавающая вокруг, навсегда, или пока вы не освободите его. Итак, у этой функции есть утечка памяти:

  int bar (int b) {int a = новый int ();  а = б;  return a;  }  

Это то же самое, что и наш первый пример , за исключением того, что теперь мы выделяем a в куче вместо стека. Так что это никогда не выходит за рамки. Он просто остается в памяти навсегда, пока процесс не завершится. Оператор new смотрит на тип, который мы ему передали (это int в данном случае) и вызывает malloc для нас с соответствующее количество байтов. Поскольку int не имеет конструкторов или деструкторов, это фактически эквивалентно этому:

  int bar (int b) {int a = (int  malloc (sizeof ( int));  а = б;  return a;  }  

Теперь люди, знакомые с C, узнают что любой вызов malloc должен сопровождаться вызовом free , так как же это сделать на C ++? Мы используем delete :

  int bar (int b) {int a = new int ();  а = б;  int r = a;  удалить;  return r;  }  

ВАЖНО: Никогда смешайте новые и бесплатно или malloc и удалить . новый / удалить операторы могут использовать другой распределитель, отличный от malloc / бесплатно , поэтому, если вы относитесь к ним как к взаимозаменяемым, они могут взорваться. Всегда бесплатно что-то из malloc и всегда удаляйте что-то, созданное с помощью new .

Теперь у нас нет утечки памяти, но мы также не можем return a больше, потому что мы не можем выполнить необходимую очистку. Если бы мы выделяли память в стеке, C ++ очистил бы нашу переменную после оператора return , но мы можем ‘ Не помещайте что-либо после оператора return, поэтому нет способа указать C ++ скопировать значение a и затем вручную удалите a без введения новой переменной r . Конечно, если бы мы могли запускать произвольный код, когда наша переменная выходит за пределы области видимости, мы могли бы решить эту проблему! Звучит как работа для конструкторов и деструкторов!

Конструкторы и удалить

Хорошо, давайте соберем все вместе и вернемся к нашему исходному утверждению в более полном примере:

  struct Foo {// Конструктор для Foo Foo (int b) {a = b;  } // Пустой деструктор для Foo ~ Foo () {} int a;  };  int bar (int b) {// Создаем Foo foo = new Foo (b);  int a = foo-> a;  // Уничтожить delete foo;  вернуть;  // Все еще не могу вернуть foo-> a}  

В этом коде мы все еще не решили проблему возврата, но теперь мы используем конструкторы и деструкторы, поэтому давайте рассмотрим, что происходит. Во-первых, new выделяет память в куче для вашего типа. Foo содержит 64 – целое битовое число, так что это 4 байта . Затем после выделяется память, новая автоматически вызывает конструктор , который соответствует любым параметрам переходите к типу. Вашему конструктору не нужно выделять память для хранения вашего типа, поскольку new уже сделал это за вас. Затем этот указатель присваивается foo . Затем мы удаляем foo , который сначала вызывает деструктор (который ничего не делает) и затем освобождает память. Если вы не передаете никаких параметров при вызове new Type () или создаете массив, C ++ просто вызвать конструктор по умолчанию (конструктор, не принимающий параметров). Все это эквивалентно:

  int bar (int b) {// Создаем Foo foo = (Foo  malloc (sizeof (Foo));  новый (foo) Foo (b);  // Специальный новый синтаксис, который вызывает ТОЛЬКО функцию конструктора (именно так вы вызываете конструкторы вручную в C ++) int a = foo-> a;  // Уничтожаем foo-> ~ Foo ();  // Однако мы можем вызвать функцию деструктора напрямую free (foo);  вернуть;  // Все еще не могу вернуть foo-> a}  

Здесь используется специальный новый синтаксис, который ничего не выделяет и просто позволяет нам вызывать функцию-конструктор непосредственно в нашей уже выделенной памяти. Это то, что новый оператор делает для вас под капотом. Затем мы вызываем деструктор вручную (что вы можете сделать) и освобождаете нашу память. Конечно, все это по-прежнему бесполезно, потому что мы не можем вернуть целое число, которое мы разместили в куче!

Деструкторы и время жизни

Теперь волшебный часть C ++ состоит в том, что конструкторы и деструкторы запускаются , когда что-то выталкивается или извлекается из стека

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

  struct Foo {// Конструктор по умолчанию для Foo Foo () {a = new int ();  } // Деструктор освобождает выделенную память с помощью delete ~ Foo () {delete a;  } int a;  };  int bar (int b) {Foo foo;  foo.a = b;  return foo.a;  // Нет утечки памяти!  }  

Как это избежать утечки памяти? Давайте рассмотрим, что происходит: сначала мы объявляем Foo foo в стеке, который помещает в стек 4 байта, а затем C ++ вызывает наш конструктор по умолчанию. Внутри нашего конструктора по умолчанию мы используем new , чтобы выделить новое целое число и сохранить его в int a . Возвращаясь к нашей функции, мы затем устанавливаем наш целочисленный указатель foo.a на б . Затем мы возвращаем значение, хранящееся в foo.a из функции [2] . Это сначала копирует значение из foo.a путем разыменования указателя, а затем C ++ вызывает наш деструктор ~ Foo перед извлечением Foo foo из стека. Этот деструктор удаляет int a , гарантируя отсутствие утечки памяти. Затем мы извлекаем int b из стека, и функция возвращается. Если бы мы могли как-то сделать это без конструкторов или деструкторов, это выглядело бы так:

int bar (int b) {Foo foo; foo.a = новый int (); foo.a = b; int retval = foo.b; удалить; вернуть retval; }

Возможность запускать деструктор, когда что-то гаснет области видимости – невероятно важная часть написания хорошего кода на C ++, потому что, когда функция возвращает все ваши переменные выходят за пределы области видимости при извлечении стека. Таким образом, вся очистка, которая выполняется во время деструктора, гарантированно запускается независимо от того, когда вы вернетесь из функции. Деструкторы гарантированно работают Stack for initialized a and b, даже если вы генерируете исключение! .Это означает, что если вы создадите исключение, которое перехватывается дальше в программе, у вас не будет утечки памяти, потому что C ++ гарантирует, что все на стек правильно уничтожается при обработке обработки исключений, поэтому все деструкторы запускаются в том же порядке, в котором они обычно находятся.

Это основная идея интеллектуальных указателей – если указатель хранится внутри объекта, и этот объект удаляет указатель в деструкторе, тогда вы никогда не потеряете указатель, потому что C ++ гарантирует, что деструктор в конечном итоге будет вызван, когда объект выйдет за пределы области видимости. Теперь, если реализовано наивно, нет возможности передавать указатель в разные функции, поэтому утилита ограничена, но C ++ 32 введено семантика перемещения , чтобы помочь решить эту проблему. Об этом мы поговорим позже. А пока давайте поговорим о различных видах времени жизни и о том, что они означают при вызове конструкторов и деструкторов.

Статическое время жизни

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

  struct Foo {// Конструктор по умолчанию для Foo Foo () {a = new int ();  } // Деструктор освобождает выделенную память с помощью delete ~ Foo () {delete a;  } int a;  статический экземпляр Foo;  };  static Foo GlobalFoo;  int main () {GlobalFoo.a = 3;  Foo :: instance.a = GlobalFoo.a;  return Foo :: instance.a;  } 

Когда экземпляр построен? Когда создается GlobalFoo ? Можем ли мы сразу же безопасно назначить GlobalFoo.a ? Ответ состоит в том, что все статические времена жизни создаются еще до того, как ваша программа запускается или, более конкретно, перед main () называется. Таким образом, к тому времени, когда ваша программа достигнет точки входа ( main () ), C ++ гарантирует, что все статические объекты времени жизни уже построены. Но в каком порядке они построены? Это усложняется. Обычно статические переменные создаются в том порядке, в котором они объявлены в одном .cpp файле. Однако порядок создания этих .cpp файлов не определен. Таким образом, вы можете иметь статические переменные, которые зависят друг от друга внутри одного .cpp файла, но никогда между разными файлами.

Аналогично, все статические объекты времени существования деконструируются после ваша функция main () возвращается, и снова этот порядок является случайным, хотя он должны быть в порядке, обратном их построению. Технически это должно соблюдаться, даже если возникает исключение, но поскольку компилятор может предположить, что процесс завершится сразу после возникновения необработанного исключения, это ненадежно.

Статические времена жизни по-прежнему применяются к разделяемым библиотекам и создаются в тот момент, когда библиотека загружается в память – это LoadLibrary в Windows и dlopen в Linux. Большинство ядер предоставляют настраиваемую функцию, которая запускается при загрузке или выгрузке разделяемой библиотеки, и эти функции выходят за рамки стандарта C ++, поэтому нет гарантии, действительно ли были вызваны статические конструкторы, когда вы находитесь внутри DllLoad , но почти никому не нужно беспокоиться об этих крайних случаях, поэтому для любого нормального кода к тому времени, когда любая функция в вашей DLL может быть вызванным другой программой, вы можете быть уверены, что у всех статических и глобальных переменных были вызваны их конструкторы. Точно так же они разрушаются, когда разделяемая библиотека выгружается из памяти.

Пока мы здесь, в предыдущем примере есть несколько ошибок, которые следует предпринять младшим программистам. знать о. Вы заметите, что я не писал static Foo = new GlobalFoo (); это приведет к утечке памяти! . В этом случае C ++ фактически не вызывает деструктор, потому что Foo не имеет статического времени жизни, Stack for initialized a and b указатель, в котором он хранится, делает! . Таким образом, указатель получит конструктор, вызываемый перед запуском программы (который ничего не делает, потому что это примитивный ), а затем указатель будет иметь деструктор, вызываемый после возврата main () , который также ничего не делает, что означает Foo на самом деле никогда не деконструируется или не освобождается. Всегда помните, что C ++ чрезвычайно разборчив в том, что вы делаете. C ++ не будет волшебным образом продлить время жизни Foo до времени жизни указателя, вместо этого он будет делать именно то, что вы сказали это сделать, а именно объявить примитив глобального указателя.

Еще одна вещь, которой следует избегать, – это случайно не написать Foo :: instance.a = GlobalFoo.a; , поскольку это не копирует целое число, оно копирует указатель с GlobalFoo на Foo :: instance . Это очень плохо, потому что теперь Foo :: instance будет пропускать указатель и вместо этого попытается освободить Указатель GlobalFoo , который уже был удален GlobalFoo , поэтому программа выйдет из строя, но только ПОСЛЕ успешного возврата 3. Фактически, произойдет сбой за пределами main () , что будет выглядеть очень странно, если вы не знаете, что происходит.

Неявные конструкторы и временные времена жизни

Время жизни в C ++ может быть сложным, потому что оно применяется не только к функциональным блокам, но и к параметрам функций, возвращаемым значениям и выражениям. Это означает, что, например, если мы вызываем функцию и создаем новый объект внутри вызова функции, существует неявное время жизни, которое существует на время вызова функции, которое четко определено, но очень странно, если вы не точно знают, что происходит. Давайте посмотрим на простой пример вызова функции, которая создает объект:

  class Foo {// Неявный конструктор для Foo Foo (int b) {a = b;  } // Пустой деструктор для Foo ~ Foo () {} int a;  } int get (Foo foo) {return foo.a;  } int main () {вернуть получить (3);  }  

Чтобы понять, что здесь происходит, нам нужно понимать неявные конструкторы , которые являются «особенностью» C ++, которую вы никогда не хотели, но все равно получили. В C ++ все конструкторы, которые принимают ровно 1 аргумент, являются неявными , что означает, что компилятор попытается использовать вызвать их, чтобы удовлетворить преобразование типа. В этом случае мы пытаемся передать 3 в получить () функция. 3 имеет тип int , но get () принимает аргумент типа Фу . Обычно это вызывает ошибку, потому что типы не совпадают. Но поскольку у нас есть конструктор для Foo , который принимает int , компилятор фактически вызывает это за нас, создавая объект типа Foo и передав его в функцию! Вот как это будет выглядеть, если мы сделаем это сами:

  int main () {вернуть получить (Foo (3));  }  

C ++ «услужливо» вставил этот конструктор для нас внутри вызова функции. Итак, теперь, когда мы знаем, что наш объект Foo создается внутри вызова функции, мы можем задать другой вопрос: когда Конструктор вызывается точно? Когда он разрушен? Ответ заключается в том, что все выражения в вызове функции сначала оцениваются слева направо. Наше выражение выделило новый временный объект Foo , поместив его в стек и затем вызвав конструктор. Однако имейте в виду, что компиляторы

не Не всегда так хорошо соблюдать порядок инициализации в вызовах функций или других списках инициализации. Но якобы они предположительно оцениваются слева направо.

Итак, как только все выражения внутри параметров были оценены, мы помещаем параметры в стек и копируем в них результаты выражений, выделяем место в стеке. для возвращаемого значения, а затем мы входим в функцию. Наша функция выполняется, копирует возвращаемое значение в зарезервированное пространство, завершает очистку и возвращается. Затем мы что-то делаем с возвращаемым значением и извлекаем наши параметры из стека. Наконец, после того, как весь шаблон параметра функции был готов, наши выражения выходят из области видимости в обратном порядке. Это означает, что деструкторы вызываются справа налево после возврата из функции. Все это примерно эквивалентно следующему:

  int main () {int b;  {Foo a = Foo (3);  // Построить Foo b = get (a);  // Вызвать функцию и скопировать результат} // Деконструировать Foo return b;  }  

Эта же логика работает для всех выражений – если вы создать временный объект внутри выражения, он существует в течение всего выражения. Однако точный порядок, в котором C ++ оценивает выражения, следующий чрезвычайно сложный и не всегда определенный , поэтому его немного сложнее определить. Вообще говоря, объект создается прямо перед тем, как ему нужно оценить выражение, а потом деконструируется. Это «временные времена жизни», потому что объект существует в выражении недолго и разрушается после вычисления выражения. Поскольку выражения C ++ не всегда упорядочены, не следует пытаться полагаться на какой-либо порядок конструкторов для произвольных выражений. В качестве примера мы можем встроить нашу предыдущую функцию get () :

  int main () {return Foo (3) .a;  }  

Это выделит временный объект типа Foo , постройте его с помощью 3 , скопируйте значение из a , а затем деконструируйте временный объект перед вычислением оператора возврата. По большей части вы можете просто предположить, что ваши объекты будут созданы до того, как произойдет выражение, и будут уничтожены после того, как это произойдет – постарайтесь не полагаться на более конкретный порядок, чем это. Конкретные правила упорядочения также меняются в C ++ 32, чтобы сделать его более строгим, а это означает, что, насколько строгий порядок будет зависеть от того, какой компилятор вы используете, пока все не будут реализовывать

Для записи, если вы не хотите, чтобы C ++ «помогал» превращал ваши конструкторы в неявные, вы можете использовать явное ключевое слово для отключения этого поведения:

  struct Foo {явное Foo (int b) {a = b;  } ~ Foo () {} int a;  };   

Статические переменные и локальное время жизни потоков

Статические переменные внутри функции (не структуры!) Работают по совершенно другим правилам, потому что это C ++ и согласованность предназначена для слабых.

  struct Foo {явный Foo (int b) {a = b;  } ~ Foo () {} int a;  };  int get () {статический Foo foo (3);  return foo.a;  } int main () {return получить () + получить ();  } 

Когда foo построен? Это не при запуске программы – она ​​фактически создается только при первом вызове функции . C ++ вводит некий магический код, в котором хранится глобальный флаг, сообщающий, инициализирована ли статическая переменная. При первом вызове get () он будет ложным, поэтому вызывается конструктор и устанавливается флаг правда. Во второй раз флаг истинен, поэтому конструктор не вызывается. Так когда же он будет разрушен? После main () возвращается и программа завершается, как и глобальные переменные!

Теперь эта статическая инициализация гарантированно является поточно-ориентированной, но это полезно только в том случае, если вы собираетесь делиться значением через несколько потоков, что обычно не работает очень хорошо, потому что только инициализация является поточно-ориентированной, а не доступом к переменной. C ++ представил новое время жизни под названием thread_local , которое еще более странно. Локальные статические переменные потока существуют только в течение потока , которому они принадлежат. Итак, если у вас есть локальная статическая переменная потока в функции, она создается при первом вызове функции для каждого потока и уничтожается при выходе из каждого потока , а не из программы. Это означает, что вы гарантированно получите уникальный экземпляр этой статической переменной для каждого потока, что может быть полезно в определенных ситуациях параллелизма.

Я не собираюсь потратьте больше времени на thread_local , потому что чтобы понять это, вам действительно нужно знать, как работает параллелизм C ++, что выходит за рамки для этого сообщения в блоге. Вместо этого давайте кратко рассмотрим семантику перемещения.

Семантика перемещения

Давайте посмотрим на реализацию интеллектуального указателя в C ++. , unique_ptr <> .

  int get (int b) {return b;  } int main () {std :: unique_ptr 

p (новый интервал ()); р = 3; int a = получить (p.get ()); вернуть; }

Здесь мы размещаем новое целое число в куче вызвав new , затем сохраните его в unique_ptr . Это гарантирует, что, когда наша функция вернется, наше целое число будет освобождено, и мы не потеряем память. Однако время жизни нашего указателя на самом деле слишком велико – нам не нужен целочисленный указатель после того, как мы извлекли значение внутри get () . Что, если бы мы могли изменить время жизни нашего указателя? Фактическое время жизни, которое нам нужно, таково:

  int get (int b) {return b;  // Мы хотим, чтобы время жизни заканчивалось здесь} int main () {// Время жизни начинается здесь std :: unique_ptr 

p (новый int ()); р = 3; int a = получить (p.get ()); вернуть; // Время жизни здесь заканчивается}

Мы можем добиться этого, с использованием семантики перемещения Stack for initialized a and b :

  int get (std :: unique_ptr 

&& b) {return b; // Время жизни нашего указателя заканчивается здесь} int main () {// Время жизни нашего указателя начинается здесь std :: unique_ptr

p (новый int ()); р = 3; int a = получить (std :: move (p)); вернуть; // Время жизни p здесь заканчивается, но p теперь пусто}

Используя std :: move , мы передать право собственности нашего unique_ptr параметру функции. Теперь функция get () владеет нашим целочисленным указателем, поэтому, пока мы не перемещаем его снова, он выйдет из области видимости после возврата get () , который удалит его. Наша предыдущая unique_ptr переменная p теперь пуст, и когда он выходит за пределы области видимости, ничего не происходит, потому что он отказался от владения указателем, который он содержал. Вот как вы можете реализовать автоматическое управление памятью в C ++ без необходимости использовать сборщик мусора, и Rust фактически использует более сложную версию этого, встроенную в компилятор.

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


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

[2] Мы разыменование указателя здесь, потому что мы хотим вернуть значение указателя, а не самого указателя! Если вы попытаетесь вернуть сам указатель из функции, он укажет на освобожденную память и выйдет из строя после возврата из функции. Попытка вернуть указатели из функций – распространенная ошибка, поэтому будьте осторожны, если обнаружите, что возвращаете указатель на что-то. Лучше использовать unique_ptr для управления временем жизни указателей за вас.

Leave a comment

Your email address will not be published. Required fields are marked *