Лекция №7. Указатели в C++ и С#. Работа с динамической памятью.
§7.1 Понятие указателя
Указатель
— это специальная переменная, которая хранит в себе не какое-то обычное значение (например, число или символ), а адрес ячейки памяти, где находится другая переменная.
Представим, что оперативная память компьютера — это огромный шкаф с ячейками, пронумерованными подряд. Каждая ячейка имеет свой уникальный номер — адрес. Когда вы объявляете переменную, например:
cpp
int age = 25;
компилятор находит свободное место в памяти, выделяет там, скажем, 4 байта (для int), записывает туда значение 25 и запоминает, где именно это находится — по какому адресу. То есть простыми словами, указатель — это как записка с номером ячейки, где лежит нужная вещь, сама же записка — не вещь, а указание на то, где она лежит.
Давайте проведем простую аналогию. Представьте, что вы живёте в большом жилом комплексе с тысячей квартир. И вам необходимо, чтобы Полине Павловне передали книгу, которая вам очень понравилась. У вас есть 2 варианта:
- Вариант 1 (без указателя): вы копируете вещь и передаёте копию (так происходит в программировании, имейте в виду, то есть, по умолчанию, передается копия аргумента).
- Вариант 2 (с указателем): вы не копируете вещь, а просто говорите: «Она у меня в квартире 113. Вот ключ от неё».
Теперь Полина Павловна может сама пойти в квартиру 113 и взять или изменить книгу.
В этом примере:
- Квартира 113 — это адрес в памяти.
- Содержимое квартиры — это значение переменной.
- Записка с номером 113 — это указатель.
Память компьютера
— это линейная последовательность байтов, каждый из которых имеет уникальный адрес.
wireframe
┌──────────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ Адрес: │ 1000 │ 1001 │ 1002 │ 1003 │ 1004 │ 1005 │
├──────────┼──────┼──────┼──────┼──────┼──────┼──────┤
│ Значение:│ 25 │ 0 │ 0 │ 0 │ 'A' │ ... │
└──────────┴──────┴──────┴──────┴──────┴──────┴──────┘
Допустим, переменная age типа int (4 байта) хранится, начиная с адреса 1000. Тогда:
- Значение age = 25.
- Адрес переменной age = 1000.
Указатель на age — это переменная, в которой будет храниться число 1000.
§7.2 Объявление указателя
Объявление указателя
— это создание переменной, которая будет хранить адрес другой переменной.
Но просто создать указатель недостаточно — нужно указать:
- Тип данных, на которые он будет указывать.
- Имя переменной-указателя.
- Специальный символ *, который говорит: «это указатель».
Синтаксис объявления в С++ максимально простой:
text
тип_данных* имя_указателя;
Пример:
cpp
int* ptr; // указатель на целое число
double* dptr;// указатель на число с плавающей точкой
char* cptr; // указатель на символ
bool* bptr; // указатель на логическое значение
Обрати внимание
* стоит рядом с именем (или с типом — об этом позже), но важно понимать, что * относится к переменной, а не ко всему типу.
Так, где же ставить * — int* ptr или int *ptr? Оба варианта работают одинаково, все зависит от стиля:
cpp
int* ptr; // часто используется: "ptr — это указатель на int"
int *ptr; // более точно: * относится к ptr
Но вот тут уже видна разница:
cpp
int* ptr1, ptr2; // ❌ ОШИБКА ПОНИМАНИЯ!
Кажется, что обе переменные — указатели. Но на самом деле:
- ptr1 — указатель (int*).
- ptr2 — обычная переменная типа int (не указатель!).
Потому что компилятор читает это как:
cpp
int *ptr1, ptr2; // * относится только к ptr1
Поэтому лучше писать так:
cpp
// Первый стиль
int *ptr1; // явно: * относится к ptr1
int *ptr2; // и к ptr2
// Второй стиль
int* ptr1;
int* ptr2;
Указатель можно объявить отдельно, а потом присвоить ему адрес:
cpp
int age = 30;
int* ptr; // объявляем указатель
ptr = &age; // присваиваем адрес переменной age
либо же сразу инициализировать
cpp
int age = 30;
int* ptr = &age; // сразу указываем, на что указывает ptr
Оператор «&» (читается как «амперсанд») возвращает «адрес переменной в памяти»:
text
&имя_переменной
Для примера можно рассмотреть следующий код:
cpp
int age = 25;
cout << &age; // Выведет адрес, например: 0x7fff1a2b3c4d
Если необходимо посмотреть, что хранит адрес или значение, то его можно легко вывести:
cpp
int age = 30;
int* ptr = &age;
// Выведет: адрес, например, 0x7fff2a3b1234
cout << ptr;
// Выведет: 30 (значение по адресу)
cout << *ptr;
// Тоже адрес, например, 0x7fff2a3b1234
cout << &age;
Неинициализированный указатель содержит случайное значение — адрес, который может:
- Указывать на чужую память.
- Быть недоступным.
- Вызвать ошибку сегментации.
cpp
// ❌ ТАК ДЕЛАТЬ НЕ СТОИТ
int* ptr; // объявлен, но не инициализирован
cout << ptr; // Выведет "мусор" — случайный адрес!\
// ❌ АВАРИЙНОЕ ЗАВЕРШЕНИЕ! (разыменование мусорного указателя)
cout << *ptr;
// ✅ ТАК ДЕЛАТЬ СТОИТ
int* ptr = nullptr; // безопасно: явно говорим "ни на что не указывает"
Символ * используется в двух совершенно разных контекстах. В первом контексте * в объявлении — объявление указателя:
cpp
int* ptr;
во втором контексте * в выражении — разыменование указателя:
cpp
cout << *ptr;
Полная цепочка использования * и &:
cpp
int value = 100; // обычная переменная
int* ptr = &value; // ptr — указатель, хранит адрес value
*ptr = 200; // идём по адресу и записываем 200
cout << value; // вывод: 200
§7.3 Арифметика указателей
Давайте вновь представим память как дом с квартирами. Каждая квартира — это ячейка памяти, у неё есть номер (адрес). Переменные живут в этих квартирах. Если у тебя есть массив:
text
int arr[5] = {10, 20, 30, 40, 50};
То это как пять квартир подряд:
wireframe
[10] [20] [30] [40] [50]
0 1 2 3 4
↑ ↑ ↑ ↑ ↑
1000 1004 1008 1012 1016 ← номера квартир (адреса)
Адрес увеличивается на 4, так как int занимает 4 байта.
С указателями можно выполнять операции инкремента, декремента, сложение с числом, вычитание с числом, вычитать один указатель из другого (только при условии, что оба указывают на один массив). Так же указатели можно сравнивать и делать операцию присваивания.
Для работы с арифметикой указателей, необходимо сделать первое, сделать указатель на первую квартиру (первую ячйейку памяти).
cpp
int* ptr = arr; // ptr указывает на arr[0]
Если нам необходимо сдвинуть указатель вперед, то есть указывать на следующую ячейку, необходимо сделать операцию инкремента:
cpp
ptr++;// ptr указывает на arr[1]
Пример перебора массива с помощью операции инкремента:
cpp
for (int i = 0; i < n; i++) {
cout << *ptr << " "; // выводим значение
ptr++; // переходим к следующему элементу
}
Если операция инкремента проходит по адресам «вперед», то декремент возвращает указатель назад. Например, указатель указывал на значение 300, по адресу 1008:
wireframe
100 200 300 400
↑
ptr
1000 1004 1008 1012
После операции декремента:
cpp
ptr--; // шаг назад
Получаем то, что указатель указывает на значение 200 с адресом 1004:
wireframe
100 200 300 400
↑
ptr
1000 1004 1008 1012
Операции сложения и вычитания с числом работают практически аналогично с операциями инкремента и декремента. Выглядят они достаточно просто:
cpp
ptr + 2
ptr - 2
Рассмотрим аналогичные примеры с операций инкремента и декремента.
wireframe
100 200 300 400
↑
ptr
1000 1004 1008 1012
С самого начала, указатель указывает на первую ячейка массива. А что если нам необходимо указывать сразу на 4 – й элемент массива? Выполнять операцию инкремента 3 раза достаточно затруднительно. Для этого можно выполнить следующую команду:
cpp
ptr + 3
Поэтому теперь указатель будет указывать на 4-й элемент массива:
wireframe
100 200 300 400
↑
ptr
1000 1004 1008 1012
Операция вычитания с числом работает аналогично. После выполнения команды:
cpp
ptr - 3
указатель вновь будет указывать на первый элемент:
wireframe
100 200 300 400
↑
ptr
1000 1004 1008 1012
Для того, чтобы понять разность указателей, рассмотрим следующий код:
cpp
#include <iostream>
using namespace std;
int main() {
int arr[] = {100, 200, 300, 400, 500};
int* ptr1 = &arr[1]; // 200
int* ptr2 = &arr[4]; // 500
int diff = ptr2 - ptr1;
cout << "ptr2 - ptr1 = " << diff << endl; // 3
return 0;
}
В данном коде есть 2 указателя, где первый указатель указывает на элемент с индексом 1, а второй указатель на элемент с индексом 4. Разность указателей отвечает на вопрос «Какое расстояние между элементами?». В данном случае ответ 3, так как:
- arr[1] → arr[2] (первый шаг)
- arr[2] → arr[3] (второй шаг)
- arr[3] → arr[4] (третий элемет)
находится 3 элемента.
Важно так же знать, что разница может быть отрицательней, в случае, если «уменьшаемое» стоит левее, чем «вычитаемое», то есть, если бы *ptr1 указывал бы на элемент с 4 – м индексом, а *ptr2 на элемент с 1-м индексом, то значение переменной diff был – бы «-3».
Присваивание указателей работает просто. Данная операция копирует адрес из одного указателя в другой.
cpp
int arr[] = {10, 20, 30};
int* ptr1 = &arr[0]; // ptr1 → на 10
int* ptr2 = &arr[2]; // ptr2 → на 30
ptr1 = ptr2; // ptr1 теперь указывает туда же, где и ptr2
warframe
До: После:
[10] [20] [30] [10] [20] [30]
↑ ↑ → → → → → → ↑
ptr1 ptr2 ptr1, ptr2
Теперь оба указателя показывают на 30. Аналогично можно сравнивать указатели, чтобы понять:
- Указывают ли они на одно и то же место?
- Кто впереди, кто позади? (слева или справа).
cpp
int arr[] = {10, 20, 30};
int* ptr1 = &arr[1];
int* ptr2 = &arr[1];
int* ptr3 = &arr[2];
if (ptr1 == ptr2) {
cout << "ptr1 и ptr2 указывают на одно и то же"; // ✅
}
if (ptr1 < ptr3) {
cout << " ptr1 перед ptr3"; // ✅
}
if (ptr1 != ptr3) {
cout << "ptr1 и ptr3 — разные"; // ✅
}
§7.4 Динамическое распределение памяти
Всё, что мы создавали до этого, было статическим:
cpp
int arr[10]; // размер известен на этапе компиляции
Но что, если:
- Размер массива заранее неизвестен?
- Нужно создать объект во время выполнения программы?
- Нужно самому управлять временем жизни переменной?
Тогда используется динамическая память — память, которую выделяем и освобождаем вручную (в C++) или автоматически (в C#).
Программа использует два основных участка памяти, которые расположены в таблице ниже. Данные участки называются стек и куча, в каждой из которых располагаются переменные и динамические объекты для работы с программой.
| Память | Где | Кто управляет |
|---|---|---|
| Стек | Локальные переменные, параметры функций | Автоматически (при выходе / выходе из функции) |
| Куча | Динамические объекты (new, malloc) | Вручную (C++) или сборщиком мусора (C#) |
Для того, чтобы выделить динамическую память в С++, необходимо использовать два оператора: new – для выделения памяти, delete – для ее освобождения.
Для примера рассмотрим создание одномерного массива массива:
cpp
int n;
cout << "Размер массива: ";
cin >> n;
int* arr = new int[n]; // массив из n элементов
for (int i = 0; i < n; i++) {
arr[i] = i * 10;
cout << arr[i] << " ";
}
delete[] arr; // обязательно [] для массивов!
arr = nullptr;
В C# динамическая память управляется автоматически:
csharp
int n = int.Parse(Console.ReadLine());
int array = new int[n];
for (int i = 0; i < n; i++)
{
array[i] = i * 10;
Console.WriteLine(array[i]);
}
Сборщик мусора (Garbage Collector, GC) сам найдёт, когда arr станет не нужен, и освободит память. Как мы видим, в C# не нужно использовать delete / delete[].
Как мы видим, в C# не нужно использовать delete / delete[], так как за нас это автоматически делает сборщик мусора.
§7.5 Использование ключевого слова const с указателями
Ключевое слово const означает «константа» — то, что нельзя изменять. С указателями const можно использовать по-разному, и от этого зависит:
- Можно ли менять значение, на которое указывает указатель?
- Можно ли менять сам указатель (на что он указывает)?
В C++ есть три основных случая, и они очень важны для безопасности и чёткости кода.
1. const перед типом → указатель на константное значение
cpp
const int* ptr;
// или: int const* ptr
Указатель указывает на константное значение. Сам указатель может меняться, но значение по адресу — нельзя.
cpp
int a = 10;
int b = 20;
const int* ptr = &a;
*ptr = 30; // ❌ ОШИБКА! Нельзя менять значение
ptr = &b; // ✅ Можно! Указатель теперь указывает на b
ptr может переключаться между переменными, но не может их изменять.
2. const после * → константный указатель
cpp
int* const ptr = &a;
Сам указатель — константа. Он не может указывать на другое место, но значение по адресу можно менять.
cpp
int a = 10;
int b = 20;
int* const ptr = &a;
*ptr = 30; // ✅ Можно! Меняем a → теперь a = 30
ptr = &b; // ❌ ОШИБКА! Нельзя менять указатель
Указатель навсегда привязан к a, но может менять a.
3. const и до, и после → константный указатель на константное значение
cpp
const int* const ptr = &a;
// или: int const* const ptr = &a
В данном случае, ни указатель, ни значение нельзя менять.
cpp
int a = 10;
int b = 20;
const int* const ptr = &a;
*ptr = 30; // ❌ ОШИБКА! Нельзя менять значение
ptr = &b; // ❌ ОШИБКА! Нельзя менять указатель
§7.6 Указатель на указатель
Указатель на указатель
— это переменная, значением которой является адрес другого указателя (который, в свою очередь, может указывать на данные или быть нулевым)
Обычный указатель типа int объявляется с использованием одной звёздочки:
cpp
int *ptr; // указатель типа int, одна звёздочка
Указатель на указатель типа int объявляется с использованием двух звёздочек:
cpp
int **ptrptr; // указатель на указатель типа int
Пример использования указателя на указатель ниже:
cpp
int** pptr; // указатель на указатель на int
double** dptr; // указатель на указатель на double
char** cptr; // указатель на указатель на char
Для простоты понимания проведем аналогию с книгами. Представьте, что у вас есть книга – переменная book. У вас есть записка с номером полки, где лежит кнга – это ptr. А еще есть вторая записка, на которой написано: «Первая записка лежит в ящике №5» — это ptrptr.
Тогда:
- *ptr → взять книгу с полки
- **pptr → сначала найти первую записку (по адресу из pptr), потом по её адресу — найти книгу
Обычно указатель на указатель применяется для создания многомерных динамических массивов, потому что это исходит из особенности создания многомерного динамического массива в С++.
Пример использования указателя на указатель:
cpp
#include <iostream>
using namespace std;
int main() {
int value = 42;
int* ptr = &value; // ptr — указывает на value
int** pptr = &ptr; // pptr — указывает на ptr
// Выводим всё по уровням
cout << "value = " << value << endl; // 42
cout << "ptr = " << ptr << endl; // адрес value
cout << "*ptr = " << *ptr << endl;// 42 (через ptr)
cout << "pptr = " << pptr << endl; // адрес ptr
cout << "*pptr = " << *pptr << endl;// адрес value (т.е.ptr)
cout << "**pptr = " << **pptr << endl; // 42 (значение)
return 0;
}
§7.7 Передача указателей в функции
Указатели
— это эффективный способ передачи функции областей памяти, содержащих значения и способных содержать результат.
При использовании указателей с функциями важно гарантировать, что вызываемой функции позволено изменять только те параметры, которые вы хотите изменить, но не другие.
cpp
#include <iostream>
using namespace std;
// Функция, которая принимает указатель
void changeValue(int* ptr) {
*ptr = 100; // меняем значение по адресу
}
int main() {
int number = 25; // обычная переменная
int* ptr = &number;// ptr указывает на number
cout << "До вызова функции: number = " << number << endl;
changeValue(ptr); // передаём указатель в функцию
cout << "После вызова: number = " << number << endl;
return 0;
}
Данная программа выведет следующее:
text
До вызова функции: number = 25
После вызова: number = 100
Почему это работает? Передавая указатель, мы передаём адрес переменной, функция получает этот адрес и может читать и изменять значение по нему. Это не копия, а прямой доступ к оригиналу. То есть, передавая копию (в параметр функции передать значение без &), мы бы значение изменить не смогли.
§7.7.1 Передача аргумента по значению
При передаче аргумента по значению (pass by value) функция получает копию исходного значения переменной. Изменения, внесённые в параметр внутри функции, не затрагивают оригинальную переменную. В языках С++ и C# это действие происходит по умолчанию и оно одинаково для двух языков:
cpp
void increment(int x) {
x++; // Изменяется только локальная копия
}
int main() {
int a = 5;
increment(a); // a остаётся равным 5
cout << a; // 5
}
Преимущества передачи аргументов по значению:
- Аргументы, переданные по значению, могут быть переменными (например, x), литералами (например, 8), выражениями (например, x + 2), структурами, классами или перечислителями (т.е. почти всем, чем угодно).
- Аргументы никогда не изменяются функцией, в которую передаются, что предотвращает возникновение побочных эффектов.
Недостатком передачи аргументов по значению является то, что копирование структур и классов может привести к значительному снижению производительности (особенно, когда функция вызывается много раз, что и приводит к понижению производительности вашей программы).
Передачу по значению стоит использовать тогда, когда предполагается, что функция не должна изменять аргумент.
§7.7.2 Передача аргумента по адресу
При передаче аргументов по значению, единственный способ вернуть значение обратно в вызывающий объект — это использовать возвращаемое значение функции.
Но иногда случаются ситуации, когда нужно, чтобы функция изменила значение переданного аргумента. Передача по ссылке и по адресу решает все эти проблемы.
Передача аргументов по адресу
— это передача адреса переменной-аргумента (а не исходной переменной). Поскольку аргумент является адресом, то параметром функции должен быть указатель.
Затем функция сможет разыменовать этот указатель для доступа или изменения исходного значения. Вот пример функции, которая принимает параметр, передаваемый по адресу:
cpp
void swap(int* a, int* b) {
// читаем значение по адресу a
int temp = *a;
// записываем в a значение из b
*a = *b;
// записываем в b сохранённое значение
*b = temp;
}
int main() {
int x = 10;
int y = 20;
cout << "До: x = " << x << ", y = " << y << endl;
// передаём адреса переменных
swap(&x, &y);
cout << "После: x = " << x << ", y = " << y << endl;
return 0;
}
В результате программа выведет следующее:
text
До: x = 10, y = 20
После: x = 20, y = 10
В C# нет указателей по умолчанию, но есть ключевое слово ref, которое позволяет передавать по ссылке (по адресу).
csharp
void Swap(ref int a, ref int b) {
int temp = a;
a = b;
b = temp;
}
int x = 10;
int y = 20;
Console.WriteLine($"До: x = {x}, y = {y}");
Swap(ref x, ref y); // передаём по ссылке
Console.WriteLine($"После: x = {x}, y = {y}");
В результате программа выведет следующее:
text
До: x = 10, y = 20
После: x = 20, y = 10
§7.7.3 Возврат значений по ссылке, по адресу и по значению
В функциях можно не только принимать аргументы по значению, адресу или ссылке — но и возвращать результат тремя способами:
| Способ | Что вернется | Когда использовать |
|---|---|---|
| По значению | Копия результата | Обычные случаи |
| По адресу | Адрес переменной | Когда надо вернуть указатель |
| По ссылке | Ссылка на переменную | Когда нужно вернуть «настоящую переменную» |
Возврат по значению в С++ самый частый:
cpp
int add(int a, int b) {
return a + b; // возвращаем значение
}
int result = add(3, 4); // result = 7
В случае, если структура большая, то возврат по значению замедляет программу. Чтобы это предотвратить, используется возврат по по адресу:
cpp
int* createArray(int size) {
int* arr = new int[size]; // выделяем в куче
for (int i = 0; i < size; i++) {
arr[i] = i * 10;
}
return arr; // возвращаем адрес массива
}
int main() {
int* myArray = createArray(5); // получаем адрес
cout << myArray[0]; // 0
delete[] myArray; // не забываем удалить!
return 0;
}
В случае, если нам необходимо избежать копирования или нам необходимо модифицировать массив, используется уже возврат по ссылке:
cpp
int& getElement(int arr[], int index) {
return arr[index]; // возвращаем ссылку на элемент
}
int main() {
int numbers[] = {10, 20, 30};
// ref ссылается на numbers[1]
int& ref = getElement(numbers, 1);
ref = 999; // меняем оригинал
cout << numbers[1]; // вывод: 999
return 0;
}
В C# нет указателей и ссылок в стиле C++, но есть:
1. Возврат по значению, который происходит как обычно:
csharp
int Add(int a, int b) => a + b;
2. Возврат ссылки на объект, который происходит автоматически:
csharp
Person CreatePerson() {
// возвращаем ссылку на объект
return new Person("Alice");
}
3. Возврат по ссылке (ref return) — продвинутый случай
csharp
public ref int Find(int[] arr, int value) {
for (int i = 0; i < arr.Length; i++) {
if (arr[i] == value)
return ref arr[i]; // возвращаем ссылку!
}
throw new Exception("Not found");
}
// Использование:
int[] nums = {10, 20, 30};
ref int found = ref Find(nums, 20);
found = 999; // меняем оригинал
Console.WriteLine(nums[1]); // 999
В заключение стоит разобраться, когда стоит и не стоит использовать возврат по значению, по адресу и по ссылке.
Когда использовать возврат по значению:
- При возврате переменных, которые были объявлены внутри функции.
- При возврате аргументов функции, которые были переданы в функцию по значению.
Когда не использовать возврат по значению:
- При возврате стандартных массивов или указателей (используйте возврат по адресу).
- При возврате больших структур или классов (используйте возврат по ссылке).
Когда использовать возврат по адресу:
- При возврате динамически выделенной памяти.
- При возврате аргументов функции, которые были переданы по адресу.
Когда не использовать возврат по адресу:
- При возврате переменных, которые были объявлены внутри функции (используйте возврат по значению).
- При возврате большой структуры или класса, который был передан по ссылке (используйте возврат по ссылке).
Когда использовать возврат по ссылке:
- При возврате ссылки-параметра.
- При возврате элемента массива, который был передан в функцию.
- При возврате большой структуры или класса, который не уничтожается в конце функции (например, тот, который был передан в функцию).
Когда не использовать возврат по ссылке:
- При возврате переменных, которые были объявлены внутри функции (используйте возврат по значению).
- При возврате стандартного массива или значения указателя (используйте возврат по адресу).
§7.8 Указатели и массивы
В С++ имя массива является указателем на его первый элемент:
cpp
int* ptr = arr; // Эквивалентно int* ptr = &arr[0]
А доступ к элементам происходит с помощью операции сложения с числом:
cpp
*(arr + 2) = 10; // Эквивалентно arr[2] = 10
В С++ массив передается в функции по указателю, поэтому размер теряется. Исходя из этого, необходимо размер массива передавать в параметр:
cpp
void printArray(int* arr, int size)
{
for (int i = 0; i < size; ++i)
{
cout << arr[i] << " ";
}
}
int main() {
int arr[] = {1, 2, 3};
printArray(arr, 3);
}
При работе со статическими двумерными массивами:
cpp
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
Доступ к элементам происходит с помощью обращения к определенному индексу. Но в С++ двумерный массив — это массив массивов, поэтому, благодаря указателю мы можем обратиться к определенной строке:
cpp
// Указатель на массив из 3 элементов
int (*ptr)[3] = matrix;
cout << ptr[1][2]; // 6
При работе с динамическими массивами и объектами, необходимо после выделения динамической памяти, ее очищать, так как управление динамической памятью в С++ происходит вручную:
cpp
int* dynArr = new int[5]; // Выделение памяти
dynArr[0] = 10; // Работа как с обычным массивом
delete[] dynArr; // Обязательное освобождение памяти!
Так же, в С++ отличается и создание двумерного динамического массива. Для этого необходимо использовать указатель на указатель. После того, как массив уже не нужен, выделенную динамическую память так же необходимо удалять. Все это связано с особенностью языка программирования С++, где динамической памятью необходимо управлять вручную, в противном случае, может произойти утечка памяти.
cpp
// Создание массива указателей
int** dynMatrix = new int*[2];
for (int i = 0; i < 2; ++i)
{
// Выделение памяти для каждой строки
dynMatrix[i] = new int[3];
}
// Освобождение памяти:
for (int i = 0; i < 2; ++i)
{
delete[] dynMatrix[i];
}
delete[] dynMatrix;
§7.9 Ссылочные переменные в С++
В языке C++ наряду с указателями существует ещё один мощный механизм — ссылки.
Ссылка
— это альтернатива указателю, которая позволяет работать с существующей переменной под другим именем, не создавая при этом копии.
Ссылка объявляется с помощью символа &, но в отличие от оператора «взятия адреса», в объявлении & означает создание ссылки.
cpp
int value = 10;
int& ref = value; // ref — ссылка на value
Теперь ref — это не отдельная переменная, а псевдоним для value. Любое изменение ref автоматически изменяет value, и наоборот.
cpp
ref = 20;
cout << value; // Выведет: 20
Ссылка не занимает отдельной памяти — она просто даёт другое имя существующей переменной.
У ссылок в С++ есть 3 свойства:
1. Ссылка должна быть инициализирована при объявлении, то есть нельзя объявить «висячую» ссылку:
cpp
int& ref; // ❌ Ошибка! Нельзя без инициализации
2. Ссылку нельзя переназначить, то есть после создания она навсегда связана с одной переменной.
cpp
int a = 10, b = 20;
int& ref = a;
ref = b; // ✅ Присваивание: a = 20
// ref = b; — не перенаправляет ссылку, а меняет a!
3. Ссылка не может быть nullptr. В отличие от указателя, ссылка всегда должна указывать на существующую переменную.
Наиболее часто ссылки применяются в передаче параметров в функции, особенно когда нужно:
- Избежать копирования больших объектов (например, структур или классов).
- Разрешить функции изменять переданный аргумент.
cpp
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int x = 10, y = 20;
swap(x, y); // x и y поменяются местами
Здесь a и b — ссылки на x и y. Функция работает с оригинальными переменными, а не с копиями. Это эффективно и удобно.
Сравните с передачей по указателю:
cpp
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
swap(&x, &y); // нужно передавать адреса
Ссылки делают код чище и безопаснее, избавляя от необходимости вручную разыменовывать указатели.
§7.10 Использование указателей в C#
В отличие от C++, язык C# является управляемым — памятью управляет сборщик мусора (Garbage Collector), а прямой доступ к памяти по умолчанию запрещён. Однако в некоторых случаях требуется низкоуровневая работа с памятью, и тогда C# предоставляет возможность использовать указатели, но только в специальном режиме.
Указатели в C# разрешены только в unsafe – контексте — то есть в коде, помеченном как небезопасный.
csharp
unsafe
{
int value = 10;
int* ptr = &value; // указатель на int
*ptr = 20; // разыменование
Console.WriteLine(value); // 20
}
Чтобы этот код работал, нужно:
- Включить «Allow unsafe code» в настройках проекта.
- Компилировать с флагом «unsafe».
unsafe используется редко — в случаях, когда нужна максимальная производительность.
Поскольку сборщик мусора может перемещать объекты в памяти, нельзя просто взять адрес элемента массива — он может измениться. Поэтому используется ключевое слово fixed, чтобы «закрепить» объект в памяти:
csharp
unsafe
{
int[] arr = {1, 2, 3};
fixed (int* ptr = arr)
{
for (int i = 0; i < 3; i++)
{
Console.Write(*(ptr + i) + " ");
}
}
}
В большинстве случаев вместо указателей в C# используются ссылочные параметры — ref и out.
Для наглядности, можно рассмотреть таблицу, которая указана ниже. Она показывает, в каких ситуациях используется ref (и его структуру), а в каких ситуацих указывается out (и его структуру). В таблице указаны примеры использования ссылочных параметров ref и out.
void Increment(ref int x)
{
x++;
}
int a = 5;
Increment(ref a);
Console.WriteLine(a);// 6
Ключевое слово out в C# используется для передачи параметров по ссылке, аналогично ref. Однако между ними есть важное различие: «переменная, переданная с модификатором out, не требует предварительной инициализации перед вызовом метода».
Это означает, что компилятор гарантирует, что метод, принимающий параметр с out, обязан присвоить ему значение до завершения своей работы. В то время как ref требует, чтобы переменная была инициализирована до вызова метода, out позволяет передать «пустую» переменную, которую метод затем заполнит.
csharp
void SomeRefMethod(ref int x)
{
// изменяем значение по ссылке
x = x * 2;
}
void SomeOutMethod(out int x)
{
// Переменная с модификатором out ОБЯЗАНА получить
// значение внутри метода перед его завершением
x = 50;
}
int a;
a = 10;
// OK: a инициализирована
SomeRefMethod(ref a);
int b;
// OK: b не нужно инициализировать
SomeOutMethod(out b);
// Теперь b содержит значение, установленное методом
Console.WriteLine(b);
Если закомментировать присваивание внутри SomeOutMethod, код не скомпилируется — компилятор потребует, чтобы out-параметр был проинициализирован в теле метода.
Наиболее распространённое и важное применение out — это методы по шаблону Try-Parse, такие как:
int.TryParse(string, out int result).bool bool.TryParse(string, out bool result).DateTime.TryParse(string, out DateTime date).
Эти методы возвращают значение типа bool, указывающее, удалась ли операция, а результат (если операция успешна) возвращается через out-параметр:
csharp
string input = "123";
if (int.TryParse(input, out int number))
{
Console.WriteLine($"Успешно: {number}");
}
else
{
Console.WriteLine("Некорректный формат числа.");
}