Тема указателей довольно сложна и мне часто присылают вопросы по ней. В этой статье мы подробно разберем тему указателей.
Рекомендуемая работа с памятью
Перед тем, как разбираться с указателями, сначала посмотрим, как лучше работать с памятью в Си.
Рекомендуемый способ — это прямо описать ту область памяти, которую вы хотите использовать.
Например:
1 2 3 |
int a = 5; char str[100] = "world!"; double x = 1.01; |
В этом примере мы задали целое число, массив и вещественное число. То есть мы явно дали имя элементу данных.
Если мы теперь будем использовать этот элемент данных, то достаточно указать его имя.
1 2 3 4 5 |
int PrintStr(char str) { printf("Hello, %s\n", str); printf("a=%d, x=%f", a, x); } |
Почему рекомендуется делать именно так? Три причины:
Причина 1. Соблюдение принципа изоляции кода
В языке Си данные в функцию передаются по значению. Эти значит, что копия входных параметров размещается на стеке. После выхода из функции эти данные уничтожаются. Поэтому программист уверен, что никакого влияния на остальную программу эти данные не окажут.
Причина 2. Простой вызов в отладчике
Просто добавьте имя элемента данных и вы можете легко наблюдать, что с ним происходит во время отладки.
Причина 3. Самодокументируемый код
Если мы дадим объектам данных осмысленные имена, то код будет хорошо читаться. Например:
1 2 3 4 5 |
int color; int radius; int DrawCircle (int color, int radius) { ... } |
Но так как язык Си универсален и есть много способов писать программы, то есть и такой способ как указатели.
Что такое указатели?
Указатель — это переменная, которая содержит адрес некоторого объекта в памяти.
Для работы с указателями используются два оператора:
& — получить адрес переменной (&x — адрес переменной x)
* — получить значение переменной по адресу (*px — значение по адресу px)
Рассмотрим участок памяти. Предположим, что по адресу 54100 размещена символьная переменная char x;
1 2 |
char x; // завели переменную char *px; // завели указатель |
При заведения указателя мы сразу говорим компилятору, что мы завели указатель на объект типа char. Чтобы не было путаницы в именах рекомендуется указатель начинать с символа «p».
Важный момент. Когда комплятор выделяет память под «char x», то выделяется один байт, потому что x — это символ, то есть это однобайтовая переменная. Но когда компилятор выделяет память под «char *px», то выделяется обычно 4 байта, так как адрес (в 32-х битовой системе) занимает 4 байта.
*px — читается как «взять значение по адресу, xранящемуся в px»
Теперь нам нужно записать:
- в переменную x некоторое значение,
- a в указатель px — адрес этого значения.
Для этого мы пишем следующие строки:
1 2 |
x = 59; - значение (59) // Присвоили значение переменной x px = &x; - адрес значения (54100) // Присвоили значение указателя px |
После этого мы можем работать с этим адресом в памяти как через имя переменной, так и через указатель. Получение значение через указатель называется разыменование указателя.
1 2 3 4 |
x++; // увеличили значение (x = 60) (*px)++; // увеличили значение по адресу px (x = 61) px++; // увеличили адрес (px = 54101) *px; // разыменование указателя - получили значение по адресу px (71) |
Но тут нужно быть осторожным. Если в указателе лежит адрес, который не выделен программе, то эта ситуация называется висячий указатель.
Предположим, что px — это висячий указатель. Действия с самим указателем px могут быть любыми, программа не пострадает. Но если мы выполним действие *px над памятью, которая не была выделена программе, то операционная система прекратит действие программы и напишет что-то вроде: «Программа выполнила недопустимую операцию и будет закрыта».
Преимущество указателей
Преимущество указателей в том, что они позволяют передать в функцию значение по ссылке. Слово «ссылка» означает, что мы не передаем значение, а ссылаемся на адрес этого значения. В этом случае можно внутри функции изменять значение элемента данных. Хотя указатель и будет уничтожен после выхода из функции, но мы изменили значение памяти по указателю и это изменение значения сохранится после выход из функции.
1 2 3 4 5 6 |
void AddFive(int *px) { *px = *px + 5; } ... AddFive(&a); |
Именно поэтому их часто используют.
Недостатки указателей
Главные недостатки указателей:
1. Нарушение принципов изоляции кода
Ошибка в указателе может привести к тому, чтобы будет испорчена память в случайном месте. Хорошо еще, если повезет и программа рухнет, тогда программист сразу заметит ошибку. Но если программа продолжит работу, то найти ошибку будет очень сложно, ведь она не сразу проявляется.
2. Отвлечение внимание на детали реализации
При использовании указателей программисту нужно держать в уме принципы работы с памятью, а это отвлекает от сути задачи, которая решает программист. При правильном подходе к программированию программист должен думать только решаемой задаче, и не отвлекаться на посторонние детали.
3. Плохая читаемость кода
Прямое использование переменной является самоочевидной вещью. Если мы видим x++, то сразу понимаем, что происходит, а вот если мы видим (*px)++ или *px++, то чтобы понять процесс, нужно вдумываться.
Сравним два варианта кода. Код с переменными:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
int n[]= {1, 2, 3, 4, 5, 6}; char abc[] = "world!"; void ShowArray(int n[], char abc[]) { // Обработка массива for (int i=0; i<6; i++) { printf("n=%d abc = %c\n", n[i], abc[i]); } } ShowArray(n, abc); |
и код с указателями
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
int n[]= {1, 2, 3, 4, 5, 6}; char abc[] = "world!"; int *pn = &n; char *pabc = &abc; void pShowArray(int *pn, char *abc) { for (int i=0; i<6; i++) { printf("n=%d abc = %c\n", *pn++, *pabc++); } } pShowArray(pn, pabc); |
Мы видим, что с указателями читаемость кода хуже, а преимуществ никаких мы не получили.
Указатели лучше вообще не использовать.
Конечно же, на это последует вопрос: «А как тогда изменять значения внутри функции?»
Например, так:
1 2 3 4 5 6 |
void Swap(int n[], int i, int j) { int tmp = n[i]; n[i] = n[j]; n[j] = tmp; } |
Этот код поменяет значения любых элементов массива.
Если же речь идет именно о переменных, то правильный ответ такой: если вам нужно изменять внешние значения переменных внутри функции — у вас неверно спроектирована программа.
В правильно спроектированной программе есть три вида элемента данных:
- Элементы общего назначения — этих данных немного и они должны быть глобальными для всей программы. (например, идентификатор открытой базы данных)
- Элементы модуля — сюда рекомендуется выносить все данные, которые нужны модулю, чтобы не раздувать передаваемые параметры (например, идентификаторы окон).
- Временные данные — они должны быть описаны внутри функций и удаляться при выходе.
Для указателей при таком подходе места нет. Но так как указатели широко используются в различных библиотеках, то работать с ними надо уметь.
Ответы на вопросы
Вопрос
Мы сначала передали адрес a в функцию AddFive, затем создали указатель int px(но почему именно в аргументе функции?), далее значение по адресу указателя увеличили на 5. Но тут непонятно, разве так будет работать? То есть, нужно сначала адрес присвоить указателю, как Вы показывали ранее в статье. Получится вот так:
1 2 3 4 5 6 7 |
void AddFive(int *px) { px = &a; *px = *px+5; } ...... AddFive(&a); |
Ответ
В аргументах функции ничего не создается. В аргументах указываются типы, чтобы компилятор мог проверить их при вызове функции. Адрес указателю px не надо присваивать внутри функции, так как этот адрес уже передан в качестве аргумента при вызове функции. То есть во время работы функции указатель px уже указывает на нужный адрес.
Вопрос
Я проверил код в CodeBlocks. Если мы укажем, например, вот так:
то возникнет ошибка: «px undeclared». Т.е. как видите указатель px не объявлен. А чтобы всё работало мы одновременно, c одной стороны, объявляем указатель в аргументе функции AddFive, а с другой стороны, записываем в указатель адрес a. Поэтому, непонятно почему Вы считаете, что ничего там не создаётся. Ведь память под указатель выделилась, так? И как раз, так как мы создали указатель, пусть и в аргументе функции, программа и работает.
Ответ
На этапе компиляции программы при проверке аргументов функции компилятор ничего не создает, а только проверяет соответствие типов аргументов в описании функции и при ее вызове. В данном примере в описании функции нет аргументов, а при вызове передается адрес — это первая ошибка в данном фрагменте. Вторая ошибка заключается в том, что идет обращение к переменной px, но она не объявлена, поэтому компилятор пишет, что «px undeclared», то есть «переменная px не объявлена».
Когда ошибки будут устранены и программа будет запущена, то в момент вызова AddFive(&a) произойдет следующее:
- Программа считает адрес переменной a и передаст управление функции AddFive.
- Аргументы функции (в данном случае адрес) будут размещены на стеке функции (это временное хранилище данных).
- Во время выполнения функции данные будут взяты со стека и обработаны.
- После выхода из функции стек будет очищен.
То есть в данном примере память для указателя специально не создается, а используется обычный стек для аргументов функции.
Вопрос
Ответ
1 2 3 4 |
int AddFive(int x) { return x+5; } |