Правда о значимых типах

Если вы читаете мой блог какое-то время, то вы должны знать, что меня беспокоит миф о том, что «значимые типы (value types) располагаются в стеке». К сожалению, в нашей собственной документации, так и во многих книгах, существует масса примеров прямо или косвенно поддерживающих этот миф. Я выступаю против него по нескольким причинам:

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

2. В большинстве случаев это неважно. Мы приложили множество усилий для того, чтобы сделать наше «управляемое» окружение (managed environment) таким, чтобы использование конкретного типа хранилища было скрыто от пользователя. В отличие от других языков, где ради корректности вы должны знать, располагается ли данный объект в стеке или в куче.

3. Это выражение не полное. А как насчет ссылок? Ссылки не являются значимыми типами и не являются экземплярами ссылочных типов, они являются значениями. Они должны где-то храниться. Хранятся они в стеке или в куче? Почему никто не говорит об этом? Просто потому, что у них нет соответствующего типа в системе типов C#? Это не является причиной для их игнорирования.

В прошлом, чтобы развеять этот миф, я говорил, что правильное высказывание должно быть следующим: «в реализации языка C# от Microsoftпри использовании CLR для рабочих станций, значимые типы располагаются в стеке, когда значением является локальная или временная переменная, на которую не производится замыкание (closed-over) из лямбда-выражения или анонимного метода, тело метода не является блоком итератора (iterator block) и JIT- компилятор решил не помещать значение в один из регистров».

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

· Реализация языка программирования C# от других производителей может выбрать другие стратегии распределения памяти для временных переменных; не существует требований к языку программирования, в которых было бы сказано, что структура данных под названием «стек» должна применяться для хранения локальных значений значимых типов.

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

· Лямбда-выражения и анонимные методы превращают локальные переменные в поля, выделенные в куче; они больше не располагаются в стеке.

· В текущей реализации языка для рабочих станций блоки итераторов также превращают локальные переменные в поля, выделенные в куче. В этом нет необходимости! Мы можем решить реализовывать блоки итераторов как сопрограммы (coroutines), выполняемые в волокнах (fiber) с выделенным стеком. В этом случае локальные переменные значимого типа могут располагаться в стеке волокон.

· Обычно люди забывают, что управление памятью не ограничивается «стеком» и «кучей». Регистры не располагаются ни в стеке, ни в куче, и совершенно законно расположить значимый тип в регистре, если существует регистр соответствующего размера. И почему важно знать, когда что-то располагается в стеке и не важно, когда что-то располагается в регистре? И наоборот, если понимание алгоритма управления регистрами JIT-компилятора неважно большинству пользователей, так почему алгоритм выделения объектов в стеке – важен?

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

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

· Существует три типа значений: (1) экземпляры значимых типов, (2) экземпляры ссылочных типов, и (3) ссылки. (Код на языке C# не может манипулировать экземплярами ссылочных типов напрямую; он всегда делает это с помощью ссылок. В небезопасном коде (unsafe code) указатели рассматриваются как значимые типы с целью определения требований к хранению их значений.)

· Существуют «хранилища», которые могут хранить значения.

· Каждое значение, с которым работает программа, располагается в некотором хранилище.

· Каждая ссылка (за исключением нулевой ссылки) ссылается на хранилище.

· Каждое хранилище обладает собственным «временем жизни» (lifetime). Т.е. периодом времени, в течение которого данные, находящиеся в этом хранилище являются корректными.

· Время между началом выполнения определенного метода, возвращением значения из этого метода или генерацией исключения в нем называется «активационным периодом» (activation period) выполнения метода.

· Код внутри метода может требовать использование некоторого хранилища. Если требуемое время жизни хранилища превосходит активационный период метода, тогда говорят, что хранилище «долгоживущее» (“long lived”), в противном случае хранилище называется «короткоживущим» (“short lived”). (Заметьте, что когда метод M вызывает метод N, методу M требуется хранилище для хранения параметров, передаваемых методу N и для значений, возвращаемых из метода N).

Теперь мы переходим к деталям реализации. В реализации языка C# от компании Microsoft, с использованием CLR справедливо следующее:

· Существует три типа хранилищ: стек, куча и регистры.

· Долгоживущие (long-lived) хранилища всегда располагаются в куче.

· Короткоживущие хранилища находятся в стеке или регистрах.

· Бывают ситуации, когда компилятору или среде исполнения необходимо определить, является ли конкретное хранилище долгоживущим или короткоживущим. В этом случае, благоразумно рассматривать такое хранилище как долгоживущее. В частности, хранилища для экземпляров ссылочных типов всегда считаются долгоживущими, даже когда можно доказать, что объекты короткоживущие. Таким образом, экземпляры ссылочных типов всегда располагаются в куче.

И теперь все становится на свои места:

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

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

· Часто путем анализа во время компиляции можно определить, что локальные переменные или временные значения не используются после завершения активационного периода метода, и, таким образом, они могут рассматриваться как короткоживущие и располагаться в стеке или в регистрах.

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

Оригинал статьи