АШШумилкин А. О.ОАиП · ПиТПМ
ОАиП · Лекция 07

Лекция №7. Указатели в C++ и С#. Работа с динамической памятью.

4 199 слов12 разделов0 иллюстраций
↓ Скачать Markdown

§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("Некорректный формат числа.");
}