Указатели в C

Классическая тема, которой пугают новичков

Указатели в C

Язык C — быстрый и эффективный язык программирования. Программист получает практически полную свободу над машиной, но вместе с этим ему нужно быть очень внимательным. В отличие от универсальных удобных языков вроде Python или Ruby, C обязывает разработчиков следить за кодом и самим брать ответственность за всё происходящее в программе, включая переменные и работу с памятью.

Сегодня разберём одну из самых основных вещей в языке C — указатели. Если поймёте её, то всё остальное вообще нестрашно.

Что за язык C

C — компилируемый статически типизированный язык. Он появился в 1972 году и послужил основой для многих других языков — C++, C#, Java. Это фундаментальная технология, которая даёт широкий контроль над компьютером.

Вот несколько основных вещей про C:

  • Простой. В этом языке нет сложных длинных конструкций.
  • Требует ручного управления памятью. Разработчик должен хорошо понимать, каких ресурсов требует его программа и когда их нужно освободить, чтобы не переполнить память ← мы здесь.
  • Со строгой структурой. Код должен быть правильно организован. Например, функции объявляют заранее, а переменным явно присваивают типы данных.
  • Встроенных функций почти нет. В других языках могут быть большие наборы удобных встроенных технологий, но в C нужно создавать большую часть программы самому с нуля или использовать библиотеки.

Все эти черты могут быть как минусами, так и плюсами.

Код на C может быть очень мощным и быстрым, но при этом на нём легче допустить критическую ошибку, которая может остановить компьютер. Особенно это касается работы с памятью.

Что такое указатели

Чтобы программа работала, её элементам нужно где-то существовать. Это место — оперативная память компьютера, которая делится на пронумерованные ячейки. Каждая ячейка может хранить 1 байт данных (ноль или единицу):

Когда мы создаём какой-то объект в программе (то есть переменную), он начинает занимать память. Сколько именно, зависит от типа данных объекта. 

Вот несколько примеров типов и сколько байтов в памяти им нужно:

  • char — 1 байт. Символ или маленькое число. Используется для хранения одного символа ('a', 'B', '1') или небольших чисел: от −128 до 127 или от 0 до 255.
  • unsigned char — 1 байт. Это беззнаковый символ или маленькое положительное число от 0 до 255.
  • short — 2 байта. Хранит короткое целое число от −32 768 до 32 767. Обычно используется для экономии памяти, если не нужны большие числа.
  • unsigned short — 2 байта. Короткое целое число от 0 до 65 535.
  • int — 4 байта. Вмещает целое число. Наиболее часто используемый тип для целых чисел от −2 147 483 648 до 2 147 483 647.
  • unsigned long long — 8 байт. Очень длинное неотрицательное целое число от 0 до 18 446 744 073 709 551 615.

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

Хотя сам объект может занимать разное количество байтов, указатель всегда имеет фиксированный размер. Его задача — наверняка сохранить адрес ячейки памяти, который в разных системах весит по-разному:

  • В 32-битных системах адрес занимает 4 байта.
  • В 64-битных системах адрес занимает 8 байт.

Что дают указатели

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

Что можно делать при помощи указателей:

  • Работать с динамической памятью. Если заранее неизвестно, сколько памяти потребуется для хранения объекта, с указателем это количество можно рассчитать во время работы программы.
  • Быстро передавать данных. Вместо копирования больших объектов можно передавать только их адрес, который всегда занимает фиксированный объём — 4 или 8 байт.
  • Изменять данные в функциях. В C при передаче аргументов в функции создаётся копия данных. Если нужно изменить оригинальные данные, нужно использовать указатели, которые позволят работать с данными напрямую в памяти.
  • Реализовать сложные проекты, которые требуют точного управления памятью.

❗️ Указатель — это не переменная! Это лишь адрес начала нужной переменной.

Синтаксис указателей

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

Вот основные правила для объявления указателей.

  • Указатель должен быть того же типа, что и объект, на который он будет указывать. Если нам нужен указатель на переменную типа int, он тоже должен быть типа int.
  • Чтобы сказать программе, что это именно указатель, после объявления типа нужно поставить астериск, например: int*.
  • После объявления типа указателю нужно придумать имя. Хорошим тоном считается давать имена, начинающиеся со строчной буквы p, от pointer. После буквы p пишут с прописной буквы имя переменной, на которую он указывает. Например, если указатель указывает на переменную number, его можно назвать pNumber.
  • Чтобы передать в указатель адрес переменной, нужно поставить после объявления указателя знак равенства, знак амперсанда & и набрать имя переменной. Например, &number

❗️ Астериск — это символ звёздочки → *

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

// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h> 

int main() {
   /* объявляем переменную number
   и присваиваем ей значение 42 */
   int number = 42; 

   /* объявляем указатель типа int.
   пока он ни на что не указывает */
   int* pNumber;    
  
   /* присваиваем указателю адрес переменной number.
   теперь pNumber указывает на number */
   pNumber = &number; 

   /* выводим адрес, который хранится в указателе pNumber
   это тот же адрес, что и у переменной number */
   printf("Адрес, хранящийся в pNumber: %p\n", pNumber); 

   // завершаем программу с кодом 0 (успешное выполнение)
   return 0; 
}

Запускаем код и видим адрес ячейки памяти, где хранится значения переменной number. Этот адрес хранится в нашем указателе pNumber:

Адрес, хранящийся в pNumber: 0x7ff7b6b6a2b8

Как выглядит использование указателей

Чтобы начать использовать указатели, нужно запомнить два правила.

Без астериска пишем имя указателя, когда присваиваем в него адрес после объявления:

pNumber = &number;

С астериском * пишем, чтобы считать значение из ячейки памяти через указатель или записать в него новое значение. Оператор * позволяет получить значение по адресу указателя. Эта операция называется «разыменование».

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

y = *pX;

А если мы хотим напрямую изменить значение в ячейке памяти, то нужно передать это значение в указатель.

В этом коде мы изменим значение переменной, напрямую обращаясь к памяти:

// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h> 

int main() {
   /* объявляем переменную number
   и присваиваем ей значение 42 */
   int number = 42; 

   /* объявляем указатель типа int.
   пока он ни на что не указывает */
   int* pNumber;    
  
   /* присваиваем указателю адрес переменной number.
   теперь pNumber указывает на number */
   pNumber = &number; 

   /* выводим адрес, который хранится в указателе pNumber
   это тот же адрес, что и у переменной number */
   printf("Адрес, хранящийся в pNumber: %p\n", pNumber); 

   /* выводим значение по адресу, хранящемуся в pNumber
   оператор * (разыменование) позволяет получить значение по адресу */
   printf("Значение, на которое указывает pNumber: %d\n", *pNumber); 

   /* выводим адрес, который хранится в указателе pNumber
   это тот же адрес, что и у переменной number */
   printf("Адрес, хранящийся в pNumber: %p\n", pNumber); 

   /* меняем значение переменной number через указатель
   теперь number = 100 */
   *pNumber = 100; 

   // выводим обновлённое значение number
   printf("Новое значение переменной number: %d\n", number); 

   // завершаем программу с кодом 0 (успешное выполнение)
   return 0; 
}

Этот код выведет:

Адрес, хранящийся в pNumber: 0x7ff7bf0cf2b8
Значение, на которое указывает pNumber: 42
Адрес, хранящийся в pNumber: 0x7ff7bf0cf2b8
Новое значение переменной number: 100

Адрес ячейки не изменился, а значение изменилось.

Самые частые проблемы

Указатели позволяют работать с динамической памятью и сильно экономить ресурсы компьютера, но при этом прямой доступ к памяти может создать серьёзные проблемы, особенно для начинающих разработчиков.

Причины большинства этих проблем — в двух ошибках.

Указатель указывает на ещё не выделенную или уже недействительную область памяти.

Почему так может быть:

  • Мы инициализировали указатель, но перед этим не создали никакого объекта, чтобы указатель мог указывать на его адрес и изменять значение объекта по этому адреса. Если в этот указатель сразу присвоить какое-то значение, программа попытается записать это значение в случайную ячейку памяти. 
  • Мы создали объект в динамической памяти, применили его и вручную освободили ячейки. Но указатель остался и продолжает указывать на старый адрес. Это называется «висячий указатель». 

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

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

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

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

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

Что дальше

В следующий раз разберём на практике, что можно делать с указателями, в чём суть основных ошибок и что можно сделать, чтобы этих ошибок не было.

Обложка:

Алексей Сухов

Корректор:

Ирина Михеева

Вёрстка:

Кирилл Климентьев

Соцсети:

Юлия Зубарева

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
А вы читали это?
hard
[anycomment]
Exit mobile version