Переменная хранит значение своего статического типа, но смысл присваивания зависит от природы этого значения. Для значимого типа копируются сами данные, поэтому последующее изменение одной переменной не затрагивает другую. Для ссылочного типа копируется ссылка на объект: несколько переменных могут обозначать один массив, и изменение его элементов становится наблюдаемым через каждый сохранившийся псевдоним.
Идентичность объекта и равенство содержимого являются разными отношениями. Два массива могут содержать одинаковую последовательность, но оставаться разными объектами; две переменные могут ссылаться на один объект при разных именах, областях действия и путях передачи. Полная, частичная, поверхностная и глубокая копии определяются тем, какие объекты результата созданы заново, а какие ссылки по-прежнему разделяются с источником.
Передача аргумента продолжает правила обычного присваивания. Параметр по значению получает копию значения аргумента: для массива это копия ссылки, поэтому метод способен изменить общий объект, но локальная замена параметра не перенаправляет переменную вызывающего кода. ref позволяет заменить эту переменную, out создаёт новое выходное значение, а in запрещает переприсваивать параметр, но не делает достижимый через него объект неизменяемым. Контракт метода обязан заранее фиксировать мутацию, сохранение входа, создание нового объекта, допустимость псевдонимов и поведение при null.
Опора — Раздел 16: значимые и ссылочные типы. Дополнительно используются nullable-операторы из Разделов 4 и 6, способы передачи ref, out и in и кортежи из Раздела 13, одномерные массивы и их алгоритмы из Раздела 15.
Каждая задача оформляется отдельной консольной программой полным шаблоном курса и компилируется с
<Nullable>enable</Nullable>. Разрешены только конструкции Разделов 1–16. Используются базовые значимые типы,enum, строки, кортежи и одномерные массивы; многомерные и зубчатые массивы, собственныеstruct, дополнительные классы, коллекции,LINQ, делегаты, лямбда-выражения,Span<T>, обработка исключений и принудительный запуск сборщика мусора запрещены. Для каждого метода явно указывается один из контрактов: изменяет переданный объект, сохраняет вход и возвращает новый объект, возвращает существующий объект, заменяет ссылку вызывающей переменной либо создаёт выходной объект черезout. Потенциально аварийные разыменования и намеренно некомпилируемые варианты исследуются только в отдельных копиях проекта.
База практических заданий
Уровень I. Базовый
Последовательность присваиваний, способ передачи, глубина копии и ожидаемый граф ссылок заданы условием. Требуется точно воспроизвести программу, после каждой значимой инструкции указать значения переменных и связи с объектами и подтвердить прогноз через вывод,
ReferenceEquals, оператор==и ручное поэлементное сравнение. Ученик не выбирает контракт: сложность возрастает от независимых значений к нескольким псевдонимам, параметрам, nullable-ссылкам и составным значениям с вложенными ссылками.
1. Независимые копии значений: Объявить
enum SignalState
{
Idle,
Active,
Failed
}
Создать int count = 12, bool valid = true, char marker = 'A', SignalState state = SignalState.Active и отдельные копии каждой переменной. После копирования изменить только исходные значения. Вывести обе группы и составить таблицу состояния после присваивания и после изменений. Объяснить, почему int, bool, char и enum одинаково демонстрируют семантику независимой копии значения.
2. Два имени одного массива: Создать
int[] first = { 2, 4, 6, 8 };
int[] second = first;
Изменить second[1], затем first[3]. После каждого шага вывести оба массива, ReferenceEquals(first, second) и first == second. Нарисовать две переменные и один объект. Объяснить, почему изменение элемента не изменяет ссылку и почему оператор == для массивов подтверждает идентичность ссылок, а не равенство последовательностей.
3. Отдельная поэлементная копия: Реализовать
static int[] CopyOf(int[] source)
и
static bool AreEqual(int[] left, int[] right)
CopyOf создаёт новый массив той же длины и переносит элементы циклом. Сравнить источник и копию через ReferenceEquals, == и AreEqual, затем изменить копию и повторить проверки. Зафиксировать три отдельных утверждения: объекты различны, содержимое до изменения одинаково, изменение копии не затрагивает источник.
4. Строка как неизменяемый ссылочный тип: Получить непустую строку через Console.ReadLine, сохранить alias = text, затем выполнить text += "!". До и после операции вывести обе строки, text == alias и ReferenceEquals(text, alias). Объяснить, почему == для string сравнивает содержимое, а конкатенация не изменяет исходный объект: создаётся новая строка и переприсваивается только переменная text.
5. Частичная копия диапазона: Реализовать
static int[] CopyRange(
int[] source,
int start,
int count)
с предусловием
Метод создаёт новый массив длины count и переносит полуинтервал [start, start + count). Проверить непустой диапазон, пустой диапазон и полную копию. После изменения результата источник должен сохраниться. Различить полную и частичную копию по охваченной области, а не по способу создания объекта.
6. Возврат существующего и нового объекта: Реализовать
static int[] ReturnSame(int[] data)
и
static int[] ReturnCopy(int[] data)
Первый возвращает переданную ссылку без создания массива, второй — независимую поэлементную копию. Сохранить оба результата, сравнить их с входом через ReferenceEquals, изменить по одному элементу каждого результата и проследить последствия. Объяснить, почему возвращаемый тип int[] сам по себе не сообщает, возвращает ли метод существующий объект или создаёт новый.
7. Значение и ссылка как обычные параметры: Реализовать
static void Change(
int number,
int[] data)
Метод прибавляет 10 к number, прибавляет 10 к data[0], а затем локально присваивает data = new[] { 100, 200 }. После вызова вывести исходное число, исходный массив и локальные значения из метода. Объяснить три независимых эффекта: изменение копии числа исчезает, изменение элемента общего массива сохраняется, локальная замена параметра не заменяет ссылку вызывающей переменной.
8. Локальная замена, ref и старый псевдоним: Реализовать
static void ReplaceLocal(int[] data)
static void ReplaceCaller(ref int[] data)
Оба метода присваивают параметру новый массив { 100, 200 }. Перед вызовами сохранить alias на исходный массив. После ReplaceLocal вызывающая переменная и псевдоним по-прежнему указывают на старый объект; после ReplaceCaller только переданная через ref переменная указывает на новый, а alias сохраняет старый. Подтвердить связи через ReferenceEquals.
9. Создание результата через out: Реализовать
static void BuildPair(
int first,
int second,
out int[] result)
Метод обязан присвоить result новый массив из двух элементов до возврата. В Main объявить выходную переменную без предварительной инициализации, вызвать метод, затем сохранить дополнительный псевдоним и изменить элемент через него. Объяснить, почему out создаёт значение для переменной вызывающего кода и почему после вызова обычные правила ссылочного типа снова допускают несколько псевдонимов одного массива.
10. Параметр in и изменяемый объект: Реализовать
static void Touch(in int[] data)
Внутри изменить data[0], но оставить закомментированную попытку
data = new int[1];
Показать, что in запрещает переприсваивать параметр-ссылку, но не запрещает менять объект, достижимый через эту ссылку. В комментарии разделить неизменность переменной-параметра и неизменность объекта.
11. null, условный доступ и индекс: Для int[]? data вычислить длину через
data?.Length ?? 0
и первый элемент через
data?[0] ?? -1
Выполнить программу для null и непустого массива. Пустой ненулевой массив проверить отдельно: ?[] защищает только от null, но после подтверждения существования объекта индекс всё равно обязан принадлежать допустимому диапазону. Аварийное обращение запускается отдельно без обработки исключения.
12. Поверхностная и глубокая копия составного значения: Дано
(
int id,
string name,
int[] samples,
bool[] flags) original
Создать shallow = original: кортеж копируется как значение, но ссылки name, samples и flags копируются без создания новых объектов. Затем создать deep, сохранив id и name, но выполнив независимые копии обоих массивов. Изменить shallow.samples[0], shallow.flags[1], затем элементы массивов deep. Вывести все три записи и отношения идентичности вложенных ссылок. Объяснить, почему общая строковая ссылка допустима для снимка содержимого из-за неизменяемости string, а изменяемые массивы требуют отдельных объектов.
Уровень II. Продвинутый
Условие задаёт требуемое наблюдаемое поведение, но не способ передачи, глубину копии, политику псевдонимов и момент публикации результата. Требуется самостоятельно выбрать мутацию, возврат нового объекта, сохранение существующей ссылки,
refлибоout, сформулировать полныйnull-контракт и доказать, какие объекты могут оставаться общими. В отличие от Уровня I ученик проектирует семантический контракт метода, а не только прослеживает уже заданные связи.
1. Идентичность и равенство nullable-массивов: Спроектировать
static bool AreSame(
int[]? left,
int[]? right)
static bool AreEqual(
int[]? left,
int[]? right)
AreSame проверяет идентичность ссылок. AreEqual сравнивает содержимое и должен определить семантику для двух null, одного null, пустых массивов, одного объекта под двумя именами и разных объектов с одинаковыми элементами. Реализация сначала использует дешёвые проверки идентичности и длины, затем выполняет поэлементный проход.
2. Независимый снимок диапазона: Спроектировать
static bool TrySnapshotRange(
int[]? source,
int start,
int count,
out int[] snapshot)
false означает null либо недопустимые границы; snapshot на этом пути получает пустой массив. Успешный пустой диапазон также возвращает пустой, но отдельный результат. При успехе снимок обязан быть новым объектом даже для полного диапазона. До создания результата полностью проверить контракт, чтобы отказ не оставлял частично заполненного массива.
3. Изменяющее и сохраняющее масштабирование: Спроектировать пару
static void ScaleInPlace(
double[] data,
double factor)
static double[] ScaledCopy(
double[] data,
double factor)
Первая изменяет переданный объект, вторая сохраняет его и возвращает новый. Создать независимую копию входа, применить к ней ScaleInPlace и подтвердить поэлементное равенство с ScaledCopy. Объяснить, почему одна математическая операция допускает два корректных API, но вызывающий код обязан различать их по имени и контракту.
4. Расширение массива: возврат или ref: Реализовать
static int[] Appended(
int[] source,
int value)
static void Append(
ref int[] source,
int value)
Обе версии создают массив длины source.Length + 1. Первая возвращает его, не изменяя переменную вызывающего кода; вторая заменяет её через ref. Перед вызовом сохранить псевдоним старого массива. Проверить пустой и непустой вход и объяснить, почему возвращаемое новое значение обычно делает замену ссылки заметнее в тексте программы, чем двунаправленный ref.
5. Копирование перекрывающихся диапазонов: Спроектировать
static bool CopySegment(
int[]? source,
int sourceStart,
int[]? target,
int targetStart,
int count)
Метод копирует count элементов и возвращает false при null или недопустимых диапазонах, не изменяя target. Требуется поддержать ReferenceEquals(source, target). Если области одного массива перекрываются и целевая начинается правее исходной, копирование выполняется справа налево; в остальных случаях — слева направо. До первого присваивания проверить все границы. Доказать, почему неверное направление уничтожает ещё не прочитанные значения.
6. Перестановка элементов и перестановка ссылок: Спроектировать
static void SwapElements(
int[] data,
int first,
int second)
static void SwapArrays(
ref int[] first,
ref int[] second)
Первый метод меняет две ячейки одного объекта. Второй меняет местами две переменные вызывающего кода без копирования элементов и создания массивов. Проверить случай, когда обе переменные изначально ссылаются на один объект, и объяснить, почему перестановка ссылок тогда не меняет наблюдаемое состояние.
7. Глубокий снимок записи: Значение
(
int version,
string label,
int[] measurements,
bool[] accepted) record
передаётся в архив. Спроектировать Snapshot, возвращающий кортеж того же типа. Значимые поля копируются непосредственно, строковая ссылка может оставаться общей, оба изменяемых массива копируются независимо. Доказать глубокую независимость серией изменений источника и архива и явно перечислить объекты, которые разрешено разделять.
8. Транзакционная нормализация: Спроектировать
static bool NormalizeInPlace(double[] data)
Для корректного непустого массива выполнить
Если массив пуст, содержит NaN, бесконечность либо все значения равны, вернуть false и сохранить каждый исходный элемент. Поэтому проверка данных и вычисление min и max завершаются до первой мутации. Сформулировать контракт согласованного отказа и объяснить, почему изменение значений во время проверки делает исходное состояние невосстановимым.
9. Сглаживание при возможном псевдониме: Новый ряд должен сохранять крайние элементы, а каждый внутренний элемент равен среднему трёх соседей исходного ряда. Спроектировать
static bool Smooth(
double[]? source,
double[]? target)
Длины должны совпадать. Самостоятельно выбрать одну политику и закрепить её контрактом:
- запретить
ReferenceEquals(source, target)и вернутьfalse; либо - поддержать совпадение ссылок через полный предварительный снимок источника.
Показать массив, на котором наивная запись слева направо искажает последующие вычисления.
10. Безопасное чтение элемента: Спроектировать
static bool TryGetAt(
int[]? data,
int index,
out int value)
false охватывает null и индекс вне [0, Length), а value присваивается на каждом пути. Метод не допускает NullReferenceException и IndexOutOfRangeException. Сравнить контракт с data?[index]: условный доступ решает только вопрос существования объекта, но не доказывает допустимость позиции.
11. Копирование при записи: Спроектировать
static bool ReplaceCopyOnWrite(
ref int[]? data,
int oldValue,
int newValue,
out int replacements)
При null вернуть false. Если совпадений нет, вернуть true, установить replacements = 0 и сохранить идентичность массива. Если замены есть, полностью построить изменённую копию и только затем присвоить её переменной через ref; старый объект остаётся неизменным для других псевдонимов. Доказать инвариант: опубликованная ссылка либо всё ещё обозначает исходный объект, либо уже обозначает полностью корректный результат.
12. Пакетное обновление с единственной публикацией: Спроектировать
static bool ApplyPatchCopyOnWrite(
ref int[]? data,
int[] indices,
int[] values,
out int changedPositions)
indices и values имеют одинаковую длину; повторяющийся индекс разрешён, и последнее указанное значение побеждает. При null, разных длинах или хотя бы одном недопустимом индексе вернуть false, не меняя ссылку и объект. После полной проверки построить копию, применить обновления и определить число позиций, чьё окончательное значение отличается от исходного. Если изменений нет, сохранить идентичность; иначе опубликовать готовую копию одним присваиванием через ref. Обосновать, почему проверка и публикация отделены от применения изменений.
Уровень III. Экспертный
Исследуется не прикладное преобразование, а граф переменных и объектов, предел языкового контракта или механизм возникновения частично наблюдаемого состояния. До сборки и запуска требуется нарисовать ссылки, предсказать идентичность, содержимое, nullable-диагностику и возможный отказ; после — объяснить наблюдение через копирование значения переменной, общность объекта, порядок публикации и достижимость. Фактическое физическое размещение данных и момент сборки памяти из эксперимента не выводятся.
1. Полная трассировка графа ссылок: Выполнить последовательность:
- создать массив
a; - присвоить
b = a; - создать независимую копию
c; - поместить
aв поле кортежаrecord; - скопировать кортеж в
recordCopy; - изменить массив через
bиrecordCopy; - передать
aобычному методу, который локально заменяет параметр; - заменить
aчерезref; - присвоить
b = null.
После каждой инструкции заранее записать, какой объект обозначает каждая переменная и каково содержимое всех существующих массивов. Затем проверить прогноз через вывод и ReferenceEquals и определить, какие старые объекты остаются достижимыми.
2. Матрица способов передачи массива: Реализовать четыре минимальных метода с параметрами
int[] data
ref int[] data
out int[] data
in int[] data
Для каждого до сборки заполнить таблицу:
- нужна ли предварительная инициализация аргумента;
- можно ли прочитать прежнюю ссылку;
- можно ли изменить элемент достигнутого массива;
- можно ли присвоить параметру новый массив;
- видит ли вызывающий код замену ссылки;
- обязателен ли модификатор в месте вызова.
Некомпилируемые варианты оставить в отдельных фрагментах с диагностикой. Особо объяснить, почему in не означает глубокую неизменяемость, а out не предоставляет прежнее значение до присваивания.
3. Идентичность строк и интернирование: Создать две одинаковые строковые литеральные переменные, строку из Console.ReadLine и строку через
new string(new[] { 'a', 'l', 'p', 'h', 'a' })
Для пар с одинаковым содержимым сравнить == и ReferenceEquals. До запуска предсказать гарантированное только для ==; результат идентичности литералов рассмотреть как следствие интернирования, а не как общий контракт string. Объяснить, почему программа, проверяющая равенство текста через ReferenceEquals, логически неверна даже тогда, когда отдельный контрольный пример возвращает true.
4. null, условный доступ и оператор подавления: Для
null
new int[0]
new[] { 10, 20 }
исследовать в отдельных запусках:
data?.Length
data?[0]
data?[5]
data!.Length
data[0]
До запуска определить значение, nullable-предупреждение либо тип исключения. После объяснить различие между статическим анализом и выполнением и то, почему ! только подавляет предупреждение компилятора, но не создаёт объект и не проверяет ссылку.
5. Перекрытие областей и направление копирования: На массиве [0, 1, 2, 3, 4, 5] исследовать копирование трёх элементов:
- из
[0, 3)в[2, 5); - из
[2, 5)в[0, 3); - между разными массивами.
Реализовать версии с обходом слева направо, справа налево и корректным выбором направления. До запуска предсказать все состояния после каждой записи. Объяснить, почему одна и та же операция над различными объектами не требует защиты от перекрытия, а при одном объекте направление становится частью корректности.
6. Копирование при записи и преждевременная публикация: Создать массив и три псевдонима, затем применить корректный ReplaceCopyOnWrite в случаях без совпадений и с несколькими заменами. Предсказать идентичность всех переменных и содержимое старого и нового объектов. После этого реализовать ошибочную версию, которая сначала присваивает новую ссылку через ref, а затем постепенно заполняет массив. Вставить наблюдаемый вывод между записями и показать, что вызывающая сторона уже видит частично построенный объект. Объяснить, почему атомарность ссылки на уровне одной инструкции не делает составное обновление транзакционным.
7. Переменная, объект и достижимость: В отдельном методе последовательно создать три массива, связать несколько переменных с первым, перенаправить часть ссылок на второй, вернуть ссылку на третий и завершить вызов. В четырёх контрольных точках нарисовать граф достижимости от локальных переменных и возвращаемого результата и определить, какие объекты становятся недостижимыми после переприсваиваний и выхода из метода. После запуска подтвердить только доступные значения и идентичность сохранившихся ссылок; не вызывать сборщик мусора и не измерять освобождение. Объяснить, почему недостижимость означает лишь возможность автоматического освобождения в будущем и почему модель «значимый тип всегда в стеке, ссылочный всегда в куче» не описывает семантику языка.
Обязательные контрольные наборы
| Задача | Исходные данные |
|---|---|
| I.1 | значения из условия; затем count = -3, valid = false, marker = 'Z', state = SignalState.Failed |
| I.2 | [2,4,6,8] |
| I.3 | [5,-1,5,0]; затем изменение копии в позиции 1 |
| I.4 | ввод "alpha" |
| I.5 | [10,20,30,40,50], диапазоны [1,4), [2,2), [0,5) |
| I.6 | [1,2,3] |
| I.7 | number = 7, data = [3,4] |
| I.8 | исходный массив [1,2,3] и псевдоним на него |
| I.9 | значения 10 и 20 |
| I.10 | [5,6,7] |
| I.11 | null, [42], [] |
| I.12 | (7, "series", [10,20,30], [true,false,true]) |
| II.1 | null/null, null/[], []/[], две ссылки на [1,2], разные массивы [1,2], массивы [1,2] и [2,1] |
| II.2 | [5,10,15,20], диапазоны [1,3), [2,2), [-1,2), [1,5) |
| II.3 | [1.5,-2.0,4.0], коэффициенты 2 и -0.5 |
| II.4 | [], [1], [1,2,3]; дополнительный псевдоним на источник |
| II.5 | [0,1,2,3,4,5], копирования [0,3) → [2,5), [2,5) → [0,3) и между двумя массивами |
| II.6 | массивы [1,2,3] и [10,20]; обмен позиций 0 и 2; две ссылки на один массив |
| II.7 | (3, "batch", [4,8,15,16], [true,false,true,true]) |
| II.8 | [2,4,6], [5,5,5], [1,double.NaN,3], [1,double.PositiveInfinity,3], [] |
| II.9 | [0,9,0,0,0]; отдельный target и совпадающие ссылки |
| II.10 | null, [], [10,20,30] с индексами -1, 0, 2, 3 |
| II.11 | null, [1,2,3] без совпадений, [1,2,1,3] с заменой 1 на 9 |
| II.12 | data = [10,20,30,40]; indices = [1,3], values = [25,45]; повтор [1,1,2] со значениями [20,21,30]; недопустимый индекс |
| III.1 | последовательность из условия |
| III.2 | исходная ссылка [1,2,3]; отдельные вызовы всех четырёх способов передачи |
| III.3 | литерал "alpha", ввод "alpha" и строка, созданная из char[] |
| III.4 | все три значения data и выражения из условия |
| III.5 | [0,1,2,3,4,5] и оба перекрывающихся направления |
| III.6 | [1,2,1,3], замена 1 на 9; случай без совпадений |
| III.7 | три массива с различными цепочками псевдонимов и возвратом третьего |
Итог модуля
Ученик различает независимую копию значения и копию ссылки, строит граф переменных и объектов и отделяет идентичность массива от равенства содержимого. Полная, частичная, поверхностная и глубокая копии определяются множеством заново созданных объектов и допустимыми общими ссылками, а неизменяемость string не переносится автоматически на другие ссылочные типы. Обычный параметр массива позволяет изменять общий объект, локальное переприсваивание остаётся внутри вызова, ref заменяет ссылку вызывающей переменной, out создаёт выходное значение, а in ограничивает параметр, но не достигнутый объект. Ученик проектирует контракты мутации, снимка, безопасного отказа, поддержки псевдонимов и копирования при записи, отделяет nullable-предупреждение от реального разыменования и рассматривает автоматическое освобождение только через достижимость без предположений о физическом размещении и моменте сборки.
Покрытие опоры и границы
- Значимые типы и независимое копирование значения: I.1, I.7; сопоставление со ссылочной семантикой — III.2.
- Копирование ссылки, несколько псевдонимов и изменение общего объекта: I.2, I.7–I.10, II.4–II.6, III.1–III.2.
- Идентичность,
ReferenceEquals,==и поэлементное равенство: I.2–I.4, I.6, II.1, III.3. - Полная и частичная независимая копия: I.3, I.5, II.2–II.5.
- Поверхностная и глубокая копия составного значения: I.12, II.7, III.1.
- Обычная передача массива, локальное переприсваивание и изменение элементов: I.7, II.3, III.2.
- Замена ссылки через
ref, создание черезoutи ограничение параметра черезin: I.8–I.10, II.4, II.6, II.11–II.12, III.2 и III.6. - Контракты «изменяет / сохраняет / возвращает существующий / возвращает новый»: I.6–I.10, II.2–II.12.
- Псевдонимы, перекрывающиеся области и выбор направления копирования: II.5, II.9, III.5.
null,?.,?[],!, nullable-анализ и безопасный доступ: I.11, II.1–II.2, II.10–II.12, III.4.- Согласованный отказ, транзакционная мутация и копирование при записи: II.2, II.8, II.11–II.12, III.6.
- Переменная, объект, достижимость и автоматическое освобождение: I.8, III.1 и III.7.
За границей: собственные структуры рассматриваются в Модуле 10, а многомерные и зубчатые графы массивов — в Модулях 7–8. Свойства, неизменяемые пользовательские типы, записи, классы, интерфейсы, обобщения, Span<T>, владение ресурсами, слабые ссылки и детальное устройство сборщика мусора относятся к Курсу II или последующим курсам. Физическое размещение отдельного значения в стеке или управляемой памяти не выводится из категории типа и не используется как учебное правило.

