Указатели. Приведение типов. Константа NULL

Практический курс по C/C++: https://stepik.org/course/193691

На предыдущем занятии мы с вами узнали следующее:

char d = 5; 
char *gpt;   // объявление указателя для работы с данными типа char
gpt = &d;   // взятие адреса переменной d и присваивание его указателю gpt
char x = *gpt; // получение значения по адресу переменной d
*gpt = 100;  // запись значения в ячейку, где расположена переменная d

Сам же указатель на уровне машинных кодов – это обычное хранилище целочисленного значения (адреса ячейки памяти), который, как правило, занимает:

  • 4 байта – для 32-х разрядных систем;
  • 8 байтов – для 64-х разрядных систем.

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

short *a, * b, *c;  // три указателя

Обратите внимание, перед каждым именем должен стоять символ *. Только тогда мы получим объявление трех указателей. Иногда начинающие программисты пишут объявление так:

short *a, b, c;  // указатель a и переменные b, c

В этом случае только a будет указателем, а b и c – обычными байтовыми переменными. Запомните, звездочку нужно прописывать перед каждым именем, если мы хотим получить три указателя.

Присваивание указателей. Приведение типов указателей

Давайте представим, что у нас в программе объявлено два указателя и только один из них указывает на переменную arg:

#include <stdio.h>
 
int main(void) 
{
         int arg = 7;
         int *ptr_arg, *ptr;
 
         ptr_arg = &arg;
 
         return 0;
}

Как сделать так, чтобы и второй указатель ptr тоже ссылался на эту же переменную arg. В данном конкретном случае можно поступить двумя способами. В первом также взять адрес переменной и присвоить второму указателю:

ptr = &arg;

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

ptr = ptr_arg;

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

#include <stdio.h>
 
int main(void) 
{
         int arg = 7;
         int *ptr_arg, *ptr;
 
         ptr_arg = &arg;
         ptr = ptr_arg;
 
         printf("*ptr = %d, arg = %d\n", *ptr, arg);
 
         *ptr_arg = 100;
         printf("*ptr = %d, arg = %d\n", *ptr, arg);
 
         return 0;
}

Мы читаем значение переменной arg с помощью указателя ptr, а меняем с помощью указателя ptr_arg. После запуска программы видим следующие строчки:

*ptr = 7, arg = 7
*ptr = 100, arg = 100

Все действительно работает, так как оба указателя ссылаются на один и тот же адрес в памяти, где хранится переменная arg.

Давайте теперь немного модифицируем программу и у второго указателя ptr поменяем тип данных с int на char:

#include <stdio.h>
 
int main(void) 
{
         int arg = 777;
         int *ptr_arg;
         char *ptr;
 
         ptr_arg = &arg;
         ptr = ptr_arg;
 
         return 0;
}

При компиляции увидим предупреждение, что мы пытаемся присваивать разные несовместимые типы, с которыми работают указатели:

assignment to 'char *' from incompatible pointer type 'int *'

Хотя, при этом, программа выполнится без ошибок и адрес будет присвоен. Это предупреждение компилятор выдает исключительно программисту, чтобы избежать возможных ошибок при работе с ячейками памяти через указатели ptr и ptr_arg. И вы уже знаете, какие проблемы могут возникнуть. Так как ptr – это байтовый указатель, то команда:

*ptr = 1;

запишет значение 1 только в одну ячейку памяти, на которую ссылается ptr. Тогда как команда:

int x = *ptr_arg;

прочитает четыре байта из памяти и из них будет сформировано целое число типа int. Поэтому при выводе результата:

*ptr = 1;
printf("*ptr_arg = %d, arg = %d\n", *ptr_arg, arg);

первоначальное значение 777 будет изменено на значение 769, а не на 1. И все из-за разницы типов данных, с которыми работают указатели. Именно поэтому компилятор и выдает в таких случаях предупреждения.

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

ptr = (char *)ptr_arg;

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

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

void *p;

Тогда мы можем совершенно свободно ему присваивать адреса любых других указателей:

p = ptr;

И, наоборот, его присваивать любым другим:

ptr_arg = p;

Компилятор в этих случаях не будет выдавать никаких предупреждений. Фактически, этот тип void* введен в язык Си исключительно для хранения адресов и их последующего присваивания другим указателям. При этом сам указатель с типом void* нельзя использовать для работы с ячейками памяти. Следующие команды приведут к ошибкам на этапе компиляции:

*p = 10;      // тип void* не позволяет заносить данные
int x = *p;   // тип void* не позволяет читать данные

И это не случайно, так как тип void не определяет какой-либо конкретный формат данных. Поэтому как, например, интерпретировать число 10 при записи в ячейки, совершенно непонятно.

Проблемы, сокрытые в указателях

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

  • указатель ссылается на произвольную (не выделенную) область памяти и в нее происходит запись некоторого значения;
  • в программе динамически выделяется память (с помощью функции malloc), но не освобождается (утечка памяти).

Про вторую ситуацию мы с вами еще подробно будем говорить, когда речь пойдет о динамическом выделении памяти и работе с функциями malloc() и free(). Сейчас сосредоточимся на первом пункте. О чем он говорит? Я, думаю, вы все уже поняли. Пусть у нас в программе объявляется некоторый указатель, а затем, используется следующим образом:

#include <stdio.h>
 
int main(void) 
{
         int *ptr;
         *ptr = 1;
         
         return 0;
}

Такая программа скомпилируется и запустится. Но, очень вероятно, что завершится с ошибкой. В данном случае, мы видим код завершения отличный от нуля:

exited with code=3221225477 in 0.73 seconds

Почему так произошло? Очевидно, мы пытаемся записать число 1 в некоторую область памяти, которая может быть отведена под какие-то другие задачи. Нельзя вот так запросто, куда угодно что-либо записывать. Сначала нужно запросить у операционной системы свободную область памяти, сказать, что мы ее будем использовать для нужд программы, а уже потом записывать туда данные. Только так. Никак иначе. И этот процесс запроса памяти называется в программировании выделением памяти. Кусок памяти выделяется операционной системой и в нее другие программы уже ничего не могут заносить. Она только наша. Пока не будет освобождена. Конечно, при завершении программы, вся выделенная память автоматически освобождается.

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

#include <stdio.h>
 
int main(void) 
{
         int arg = 5;
         int *ptr = &arg;
         *ptr = 1;
         
         return 0;
}

В этом случае программа выполнится без ошибок и завершится с кодом 0.

Константа NULL

Но все же, очень часто бывают ситуации, когда в нашем распоряжении имеется некий указатель и нам бы хотелось знать: указывает ли он на выделенную область, с которой можно работать, или на произвольную, то есть, указатель не был инициализирован? К сожалению, в языке Си нет встроенного механизма, который бы позволял гарантированно отвечать на этот вопрос. Но один прием довольно часто используется на практике. Ничто не мешаем нам договориться, если указатель принимает некоторое строго предопределенное значение, значит, он еще не был инициализирован. А иначе, ссылается на выделенную область памяти. Что может здесь выступать в качестве предопределенного значения? В языке Си для этого используется специальная константа NULL, определенная с помощью директивы define в заголовочном файле stdio.h:

int *ptr = NULL;

Для разных ОС макрос NULL может принимать разные значения. В моем случае – это нулевой указатель, заданный в виде:

#define NULL      ((void *)0)

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

#include <stdio.h>
 
int main(void) 
{
         int arg = 5;
         int *ptr = NULL;
 
         if(ptr != NULL)
                   *ptr = 1;
         
         return 0;
}

После запуска программа выполнится и завершится без ошибок. При этом команда «*ptr = 1;» выполнена не будет. Если же изменить значение указателя:

ptr = &arg;

то условие станет истинным и значение переменной arg будет изменено на единицу.

Вот так с помощью предопределенной константы NULL можно контролировать значение указателя на выделенную область памяти. Хотя, конечно, этот прием не дает гарантии. Вполне может возникнуть ситуация, когда указатель не будет равен NULL и при этом ссылаться на невыделенную область. Но это уже ложится на плечи программиста. Именно мы должны так продумывать логику работы программы, чтобы указатели всегда гарантированно содержали нужные нам адреса.

Практический курс по C/C++: https://stepik.org/course/193691

Видео по теме