Указатели в C++

Указатель — это переменная, которая указывает на некоторый участок памяти. Обычно указатель содержит адрес другой переменной, объявленной в коде ранее. Посмотрим на примере:

void main() {
    // Объявляем переменную типа int
    // И инициализируем её
    int var = 10;
}

При выполнении программы в ОЗУ будет выделен участок памяти такого размера, чтобы там свободно помещалось значение нашей переменной var. Размер выделенного участка памяти зависит от типа переменной (посмотреть ‘размер’ каждого типа можно тут); Поэтому инициализировать указатель будем адресом, где хранится значение другой переменной. Итак:

void main() {
    int  var = 10;
    // & - операция взятия адреса   
    // Результатом операции взятия адреса является адрес ячейки памяти, 
    // которая была выделена компилятором под соответствующую переменную.
    // Например, если для переменной выделена область памяти,
    // начиная с адреса 5022FE38, тогда &var будет иметь значение 5022FE38
    int* ptr = &var;
}

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

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

#include <iostream>

void main() {
    int  var = 10;
    int* ptr = &var;
    
    std::cout << "Значение переменной: " <<  var << std::endl; // 1.
    std::cout << "Значение указателя:  " <<  ptr << std::endl; // 2.
    std::cout << "Значение переменной, на которую ссылается указатель:"   << *ptr << std::endl; // 3.
}
  1. Здесь все просто — используем саму переменную.
  2. Во втором случае выведется адрес в оперативной памяти, где  расположена переменная var.  На этот же адрес ссылается указатель ptr. Например 5022FE38
  3. А в третьем случае мы обращаемся к значению переменной var через указатель ptr. Но, мы не просто используем имя указателя — здесь используется операция разыменования: она позволяет перейти от адреса к значению.

В предыдущем примере был организован только вывод значения переменной на экран. Можем ли мы используя указатель изменять значение переменной, на которую он указывает? Да, конечно! Все что нужно — сделать разыменование указателя:

void main() {
    int  var = 10;
    int* ptr = &var;

    // Увеличиваем значение переменной на единицу
    (*ptr)++; // Теперь в переменной var будет храниться число 11 
}

А сейчас поговорим о том, где можно использовать указатели.

1. Массивы

Как вы уже знаете, массив это структура данных, представленная в виде набора ячеек одного типа, объединенных под одним единым именем. Имя массива является указателем на первый элемент массива (не стоит забывать, что индекс у массива начинается с нуля). Поэтому мы можем обращаться к произвольному элементу массива зная его имя и номер элемента.

void main() {
    int size = 10;
    // Создаем массив из десяти элементов
    int array[10];
    // Инициализируем массив нулями
    for (int i = 0; i < size; i++) {
        array[i] = 0;
    }

    // Так как имя массива - указатель на первый элемент
    // То изменение третьего элемента можно осуществить так
    *(array + 2) = 1;
    // Или же получим "явно" адрес первого элемента массива
    *(&array[0] + 2) = 1;
}

На заметку: компилятор автоматически изменяет a[b] на *(a + b), и иногда можно встретить следующий код 1[a+1]. Не пугайтесь, это всего лишь обращение к третьему элементу массива: 1[a+1] == *(1 + a + 1) == a[2] 

2. Динамическое выделение памяти

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

#include <iostream>

void main() {
    // Получаем кол-во элементов в динамическом массиве
    int size = 0;
    std::cout >> size;
    
    // Создаем динамический массив
    int* array_ptr = new int[size];    
}

Что тут произошло? Мы считали целое число, после чего оператор new выделил память для size элементов и возвратил адрес в памяти. Теперь указатель array_ptr хранит в себе адрес первого элемента в памяти. С этим указателем можно работать так же как и с обычным статическим массивом:

*(array_ptr + 3) = 5; // Установили значение четвертого элемента массива

3. Указатель на указатель

Это та же переменная, которая хранит адрес другого указателя. Зачем он нужен? Например для инициализации двумерного динамического массива (матрицы):

#include <iostream>

void main() {
    // Получаем кол-во строк и столбцов в матрице
    int n, m;
    std::cout >> n >> m;
    
    // Строки матрицы будут хранить в себе указатели на столбцы матрицы
    int** matrix = new int*[n];
    for (int i = 0; i < n; i++) {
        matrix[i] = new int[m];
    }
    // Не забываем про то, что все элементы матрицы нежно проинициализировать
    // Но это задание оставлю для вас 
}

Так же существуют и тройные указатели, и, даже четверные! Но в реальных задачах они практически не встречаются.

5. Указатель в качестве аргумента функции

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

// Функция меняет элементы двух матриц местами
void replace(int** a, int** b, int size) {
    // TODO
}

void main() {
    int size = 5;
    // Создаем две матрицы
    int** a = new int*[size];
    int** b = new int*[size];
    for (int i = 0; i < size; i++) {
        a[i] = new int[size];
        b[i] = new int[size];
    }
    // Здесь инициализация всех элементов матрицы
    // Её вы можете написать сами

    replace(a, b, size);
}

Самый простой вариант – просто заменить элементы в цикле:

void replace(int** a, int** b, int size) {
    for (int row = 0; row < size; row++) {
        for (int column = 0; column < size; column++) {
            // Создаем временную переменную
            int tmp = a[row][column];
            a[row][column] = b[row][column];
            b[row][column] = tmp;
        }
    }
}

Но просто – не значит хорошо. Вспомним, что указатели указывают на какой-то участок в памяти. Почему бы нам просто не поменять указатели местами? Например так:

void replace(int** a, int** b) {
    // Временная переменная
    int** tmp = a;
    a = b;
    b = a;
}

Сработает ли это? Если внутри функции replace вывести две матрицы на экран – то увидим, что значения действительно поменялись. НО: если вывести эти же матрицы в функции main, то значения матриц не изменилось! Почему так? Все предельно просто – в функции repalce мы работали не с самим указателем, а с его локальной копией! Все изменения, которые произошли в функции replace – затронули только локальную копию указателей, но никак не сам указатель. Давайте посмотрим на следующий пример:

void replace(int a, int b) {
    int tmp = a;
    a = b;
    b = a;
}

int main() {
    int a = 1;
    int b = 2;

    replace(a, b);
    // Изменились ли здесь значения переменных a и b
}

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

void replace(int*** a,  int*** b) {
    int*** tmp = a;
    a = b;
    b = a;
}

void main() {
    int size = 5;
    // Создаем две матрицы
    int** a = new int*[size];
    int** b = new int*[size];
    for (int i = 0; i < size; i++) {
        a[i] = new int[size];
        b[i] = new int[size];
    }
    // Здесь инициализация всех элементов матрицы
    // Её вы можете написать сами

    // Создаем указатели на переменные, которые хотим заменять в функции
    // В нашем случае переменные - int** 
    int*** first_ptr  = &a;
    int*** second_ptr = &b;

    replace(a, b);
}

Вуаля! Все заработало! Без лишних операций по замене каждого элемента. Просто и элегантно поменяли адреса матриц.

Важное замечание

При работе с данными в динамической памяти (в нашем случае с динамическими массивами) не забываем отчищать память, после того как они перестали быть нам нужны. Добавляем:

delete[] a;
delete[] b;

Ну вот и все. Надеюсь вы поняли основные принципы использования указателей и где их можно использовать. Если что-то осталось непонятным – добро пожаловать в комментарии.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *