23 April 2015

Указатели в C++. Введение

C++
Хотелось бы с самого начала прояснить одну вещь — я не отношу себя к категории true-кодеров, сам учусь по специальности, не связанной с разработкой ПО, и это мой первый пост. Прошу судить по всей строгости. Итак, в свое время то ли по причине того, что я спал на лекциях, то ли я не особо вникал в эту тему, но у меня возникали некоторые сложности при работе с указателями в плюсах. Теперь же ни одна моя даже самая крохотная быдлокодерская программа не обходится без указателей. В данной статье я попытаюсь рассказать базовые вещи: что такое указатели, как с ними работать и где их можно применять. Повторюсь, изложенный ниже материал предназначен для новичков.


/* Ребят, в статье было найдено много ошибок. Спасибо тем людям, которые внесли свои замечания. В связи с этим — после прочтения статьи обязательно перечитайте комментарии */

1. Общие сведения


Итак, что же такое указатель? Указатель — это та же переменная, только инициализируется она не значением одного из множества типов данных в C++, а адресом, адресом некоторой переменной, которая была объявлена в коде ранее. Разберем на примере:

void main(){
    int i_val = 7;
}

# Здесь ниже, конечно, я ребятки вам соврал. Переменная i_val — статическая, она явно будет размещена в стеке. В куче место выделяется под динамические объекты. Это важные вещи! Но в данном контексте, я, сделав сам себе замечание, позволю оставить себе все как есть, так что сильно не ругайтесь.

Мы объявили переменную типа int и здесь же ее проинициализировали. Что же произойдет при компиляции программы? В оперативной памяти, в куче, будет выделено свободное место такого размера, что там можно будет беспрепятственно разместить значение нашей переменной i_val. Переменная займет некоторый участок памяти, разместившись в нескольких ячейках в зависимости от своего типа; учитывая, что каждая такая ячейка имеет адрес, мы можем узнать диапазон адресов, в пределах которого разместилось значение переменной. В данном случае, при работе с указателями нам нужен лишь один адрес — адрес первой ячейки, именно он и послужит значением, которым мы проинициализируем указатель. Итак:

void main(){
    // 1
    int  i_val = 7;
    int* i_ptr = &i_val;
    // 2
    void* v_ptr = (int *)&i_val
}

Используя унарную операцию взятия адреса &, мы извлекаем адрес переменной i_val и присваиваем ее указателю. Здесь стоит обратить внимание на следующие вещи:
  1. Тип, используемый при объявлении указателя в точности должен соответствовать типу переменной, адрес которой мы присваиваем указателю.
  2. В качестве типа, который используется при объявлении указателя, можно выбрать тип void. Но в этом случае при инициализации указателя придется приводить его к типу переменной, на которую он указывает.
  3. Не следует путать оператор взятия адреса со ссылкой на некоторое значение, которое так же визуально отображается символом &.

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

#include <iostream>

using namespace std;

void main(){
    int  i_val = 7;
    int* i_ptr = &i_val;
    
    // выведем на экран значение переменной i_val
    cout <<  i_val << endl; // C1
    cout << *i_ptr << endl; // C2
}

  1. Здесь все ясно — используем саму переменную.
  2. Во втором случае — мы обращаемся к значению переменной i_val через указатель. Но, как вы заметили, мы не просто используем имя указателя — здесь используется операция разыменования: она позволяет перейти от адреса к значению.

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

    (*i_ptr)++; // результат эквивалентен операции инкремента самой переменной: i_val++
              // т.е. в данном случае в i_val сейчас хранится значение не 7, а 8.

2. Массивы


Сразу перейдем к примеру — рассмотрим статичный одномерный массив определенной длинны и инициализируем его элементы:

void main(){
	const int size = 7;
	// объявление 
	int i_array[size];
	// инициализация элементов массива
	for (int i = 0; i != size; i++){
		i_array[i] = i;
	}
}

А теперь будем обращаться к элементам массива, используя указатели:

int* arr_ptr = i_array;
	for (int i = 0; i != size; i++){
		cout << *(arr_ptr + i) << endl;
	}

Что здесь происходит: мы инициализируем указатель arr_ptr адресом начала массива i_array. Затем, в цикле мы выводим элементы, обращаясь к каждому с помощью начального адреса и смещения. То есть:

*(arr_ptr + 0)
это тот же самый нулевой элемент, смещение нулевое (i = 0),
*(arr_ptr + 1)
— первый (i = 1), и так далее.

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

int* arr_ptr_null = &i_array[0];
	for (int i = 0; i != size; i++){
		cout << *(arr_ptr_null + i) << endl;
	}
Пройдем по элементам с конца массива:
int* arr_ptr_end = &i_array[size - 1];
	for (int i = 0; i != size; i++){
		cout << *(arr_ptr_end - i) << endl;
	}
Замечания:
  1. Запись array[i] эквивалентна записи *(array + i). Никто не запрещает использовать их комбинированно: (array + i)[1] — в этом случае смещение идет на i, и еще на единичку. Однако, в данном случае перед выражением (array + i) ставить * не нужно. Наличие скобок это «компенсирует.
  2. Следите за вашими „перемещениями“ по элементам массива — особенно если вам захочется использовать порнографический такой метод записи, как (array + i)[j].

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


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

int size = -1;
// здесь происходят какие - то 
// действия, которые изменяют
// значение переменной size
int* dyn_arr = new int[size];

Что здесь происходит: мы объявляем указатель и инициализируем его началом массива, под который выделяется память оператором new на size элементов. Следует заметить, что в этом случае мы можем использовать те же приемы в работе с указателями, что и с статическим массивом. Что следует из этого извлечь — если вам нужна какая — то структура (как массив, например), но ее размер вам заранее неизвестен, то просто сделайте объявление этой структуры, а проинициализируете ее уж позже. Более полный пример приведу чуть позже, а пока что — рассмотрим двойные указатели.

Что такое указатель на указатель? Это та же переменная, которая хранит адрес другого указателя „более низкого порядка“. Зачем он нужен? Для инициализации двумерного динамического массива, например:

const int size = 7;
// двумерный массив размером 7x7
int** i_arr = new int*[size];
for(int i = 0; i != size; i++){
    i_arr[i] = new int[size];
}

А тройной указатель? Трехмерный динамический массив. Неинтересно, скажите вы, так можно продолжать до бесконечности. Ну хорошо. Тогда давайте представим себе ситуацию, когда нам нужно разместить динамические объекты какого-нибудь класса MyClass в двумерном динамическом массиве. Как это выглядит (пример иллюстрирует исключительно использование указателей, приведенный в примере класс никакой смысловой нагрузки не несет):

class MyClass{
    public:
        int a;
    public:
        MyClass(int v){ this->a = v; };
       ~MyClass(){};
};

void main(){
    MyClass*** v = new MyClass**[7];
	for (int i = 0; i != 7; i++){
		v[i] = new MyClass*[3];
		for (int j = 0; j != 3; j++){
			v[i][j] = new MyClass(i*j);
		}
	}
}
Здесь два указателя нужны для формирования матрицы, в которой будут располагаться объекты, третий — собственно для размещения там динамических объектов (не MyClass a, а MyClass* a). Это не единственный пример использования указателей такого рода, чуть ниже будут рассмотрены еще примеры.

4. Указатель как аргумент функции


Для начала создадим два динамических массива размером 4x4 и проинициализируем их элементы некоторыми значениями:

void f1(int**, int);
void main(){
	const int size = 4;
        // объявление и выделение памяти 
        // под другие указатели
	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];
                // собственно инициализация
		for (int j = 0; j != size; j++){
			a[i][j] = i * j + 1;
			b[i][j] = i * j - 1;
		}
	}
}
void f1(int** a, int c){
	for (int i = 0; i != c; i++){
		for (int j = 0; j != c; j++){
			cout.width(3);
			cout << a[i][j];
		}
		cout << endl;
	}
	cout << endl;
}

Функция f1 выводит значения массивов на экран: первый ее аргумент указатель на двумерный массив, второй — его размерность (указывается одно значение, потому как мы условились для простоты работать с массивами, где количество строк совпадает с количеством столбцов).

Задача: заменить значения элементов массива a соответствующими элементами из массива b, учитывая, что это должно произойти в некоторой функции, которая так или иначе занимается обработкой массивов. Цель: разобраться в способе передачи указателей для их дальнейшей модификации.
  1. Вариант первый. Передаем собственно указатели a и b в качестве параметров функции:

    void f2(int** a, int** b, int c){
    	for (int i = 0; i != c; i++){
    		for (int j = 0; j != c; j++){
    			a[i][j] = b[i][j];
    		}
    	}
    }
    
    После вызова данной функции в теле mainf2(a, b, 4) содержимое массивов a и b станет одинаковым.
  2. Вариант второй. Заменить значение указателя: просто присвоить значение указателя b указателю a.

    void main(){
    	const int size = 4;
            // объявление и выделение памяти 
            // под другие указатели
    	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];
                    // собственно инициализация
    		for (int j = 0; j != size; j++){
    			a[i][j] = i * j + 1;
    			b[i][j] = i * j - 1;
    		}
    	}
            // Здесь это сработает
            a = b;
    }
    

    Однако, нам интересен случай, когда массивы обрабатываются в некоторой функции. Что первое приходит на ум? Передать указатели в качестве параметров нашей функции и там сделать то же самое: присвоить указателю a значение указателя b. То есть реализовать следующую функцию:

    void f3(int** a, int** b){
    	a = b;
    }
    
    Сработает ли она? Если мы внутри функции f3 вызовем функцию f1(a, 4), то увидим, что значения массива действительно поменялись. НО: если мы посмотрим содержимое массива a в main — то обнаружим обратное — ничего не изменилось. Так в чем же причина? Все предельно просто: в функции f3 мы работали не с самим указателем a, а с его локальной копией! Все изменения, которые произошли в функции f3 — затронули только локальную копию указателя, но никак не сам указатель a. Давайте посмотрим на следующий пример:

    void false_eqv(int, int);
    void main(){
        int a = 3, b = 5;
        false_eqv(a, b);
        // Поменялось значение a? 
        // Конечно же, нет
    }
    false_eqv(int a, int b){
        a = b;
    }
    
    Итак, я думаю, вы поняли, к чему я веду. Переменной a нельзя присвоить таким образом значение переменной b — ведь мы передавали их значения напрямую, а не по ссылке. То же самое и с указателями — используя их в качестве аргументов таким образом, мы заведомо лишаем их возможности изменения значения.
    Вариант третий, или работа над ошибками по второму варианту:

    void f4(int***, int**);
    void main(){
    	const int size = 4;
    	int** a = new int*[4];
    	int** b = new int*[4];
    	for (int i = 0; i != 4; i++){
    		a[i] = new int[4]; 
    		b[i] = new int[4];
    		for (int j = 0; j != 4; j++){
    			a[i][j] = i * j + 1;
    			b[i][j] = i * j - 1;
    		}
    	}
    	int*** d = &a;
            f4(d, b);
    }
    void f4(int*** a, int** b){
    	*a = b;
    }
    

    Таким образом, в main'е мы создаем указатель d на указатель a, и именно его передаем в качестве аргумента в функцию замены. Теперь, разыменовав d внутри f4 и приравняв ему значение указателя b, мы заменили значение настоящего указателя a, а не его локальной копии, на значение указателя b.

    Кстати, а чего это мы создаем динамические объекты? Ну ладно размер массива не знали, а экземпляры классов мы зачем динамическими делали? Да потому что зачастую, созданный нами объекты свое — они генерились, порождали новые данные/объекты для дальнейшей работы, а теперь пришло им время...умереть [фу, как грубо] уйти со сцены. И как мы это сделаем? Просто:

    delete(a);
    delete(b);
    // Вот и кончились наши двумерные массивы
    delete(v);
    // Вот и нет больше двумерного массива с динамическими объектами
    delete(dyn_array);
    // Вот и удалился одномерный массив
    



  3. На данной ноте я хотел бы закончить свое повествование. Если найдется хотя бы пара ребят, которым понравится стиль изложения материала, то я постараюсь продолжить… ой, да кого я обманываю, мне нужен инвайт и все на этом, дайте инвайт и вашим глазам больше не придется видеть это околесицу. Шучу, конечно. Ругайте, комментируйте.
Tags:указателиработа с памятьюC++
Hubs: C++
-4
312.9k 235
Comments 59
Top of the last 24 hours