Для чего нужен указатель в Си?
Указатель в Си — это переменная, содержащая адрес другой переменной. Сложность указателей заключается в понимании где и для чего они могут пригодиться.
Перед тем, как я начну рассказывать об указателях и зачем они программистам, быстренько вспомним основы:
Указатель
В Си указателем называется переменная, содержащая адрес другой переменной. Его можно использовать с любым типом данных, написав:
Оператор « & » (амперсанд) определяет адрес переменной, а оператор « * » разыменования позволяет получить значение по адресу, указанным указателем. В примере выше адрес i присваивается к указателю ptr и получается, что ptr указывает на i .
Для чего нужен указатель в Си
Функции в Си принимают аргументы, передавая или копируя значения в стек функции. Такой метод иногда называется передачей по значению. Поскольку функции в Си и переменные, переданные им, в действительности не связываются, любые внесённые изменения в эти переменные не будут сохраняться за пределами действия функции. Это может вызвать сложности, потому что в некоторых функциях необходимо изменять текущие переменные. Здесь-то нам и пригодится указатель. С его помощью можно получить доступ к памяти, находящейся за пределами стекового кадра. Однако важно отметить, что с помощью указателя можно получить доступ лишь к переменным, расположенным ниже текущего кадра.
В примере выше простая функция с задачей — увеличить на единицу число, проходящее через параметры. Функция написана следующим образом:
Однако при запуске кода никаких изменений с переменной не происходит. Связанно это с тем, что в функцию increment (увеличения) копируется только значение переменной, и остальная часть программы не видит изменений, внесённых в эту переменную. Другими словами, переменная i находится внутри функции increment() , и несмотря на одинаковые названия, это не одна и та же переменная, что int i , расположенная за пределами этой функции.
Для того, чтобы решить эту проблему, нужно вместо самой переменной передать в функцию increment указатель этой переменной. Таким образом мы предоставим текущей функции доступ к переменной i , которая не попадает в область действия при выполнении этой функции. Выглядит она вот так:
Указатели в Cи
Указатели это чрезвычайно мощный инструмент в программировании. С помощью указателей некоторые вещи в программировании можно сделать намного проще и при этом эффективность работы вашей программы значительно повысится. Указатели даже позволяют обрабатывать неограниченное количество данных. Например, с помощью указателей можно изменять значения переменных внутри функции, при этом переменные передаются в функцию в качестве параметров. Кроме того, указатели можно использовать для динамического выделения памяти, что означает, что вы можете писать программы, которые могут обрабатывать практически неограниченные объемы данных на лету — вам не нужно знать, когда вы пишете программу, сколько памяти нужно выделить заранее. Пожалуй, это самая мощная функция указателей. Для начала давайте просто получим общее представление об указателях, научимся их объявлять и использовать.
Что такое указатели и зачем они нужны?
Указатели похожи на метки, которые ссылаются на места в памяти. Представьте сейф с депозитными ячейками различного размера в местном банке. Каждая ячейка имеет номер, уникальный номер, который связан только с этой ячейкой, таким образом можно быстро идентифицировать нужную ячейку. Эти цифры аналогичны адресам ячеек компьютерной памяти. К примеру, у вас есть богатый дядя, который хранит все свои ценности в своем сейфе. И чтобы обезопасить все свои сбережения, он решил завести меньший сейф, в который положит карту, на которой показано местоположение большого сейфа и указан 16-й пароль от большого сейфа, в котором и хранятся реальные драгоценности. По сути, сейф с картой будет хранить расположение другого сейфа. Вся эта организация сбережения драгоценностей эквивалентна указателям в языке Си. В компьютере, указатели просто переменные, которые хранят адреса памяти, как правило, адреса других переменных.
Идея в том, что зная адрес переменной, вы можете пойти по этому адресу и получить данные, хранящиеся в нем. Если вам нужно передать огромный кусок данных в функцию, намного проще передать адрес в памяти, по которому хранятся эти данные, чем скопировать каждый элемент данных! Более того, если программе понадобится больше памяти, вы можете запросить больше памяти из системы. Как же это работает? Система просто возвращает адрес ячейки памяти, и мы должны сохранить этот адрес в переменной-указателе. Так мы сможем взаимодействовать с данными из указанного участка памяти.
Если у нас есть указатель, значит мы можем получить его адрес в памяти и данные на которые он ссылается, по этой причине указатели имеют несколько необычный синтаксис, отличающийся от объявления простых переменных. Более того, поскольку указатели — это не обычные переменные, то, необходимо сообщить компилятору, что переменная является указателем и сообщить компилятору тип данных, на которые ссылается указатель. Итак, указатель объявляется следующим образом:
где, data_type — тип данных, pointerName — имя указателя.
Например, объявим указатель, который хранит адрес ячейки памяти, в которой лежит целое число:
Обратите внимание на использование символа * , при объявлении указателя. Этот символ является ключевым в объявлении указателя. Если в объявлении переменной, непосредственно перед именем переменной, добавить этот символ, то переменная будет объявлена как указатель. Кроме того, если вы объявляете несколько указателей в одной строке, каждый из них должен предваряться символом звездочки. Рассмотрим несколько примеров:
Как я и говорил, если имя переменной не предваряется символом * , то это обычная переменная, в противном случае — это указатель. Именно это и показывает пример объявления указателей, выше.
Есть два способа использования указателя:
- Использовать имя указателя без символа * , таким образом можно получить фактический адрес ячейки памяти, куда ссылается указатель.
- Использовать имя указателя с символом * , это позволит получить значение, хранящееся в памяти. В рамках указателей, у символа * есть техническое название — операция разыименования. По сути, мы принимаем ссылку на какой-то адрес памяти, чтобы получить фактическое значение. Это может быть сложно для понимания, но в дальнейшем постараемся разобраться во всем этом.
Объявление указателя, получение адреса переменной
Для того чтобы объявить указатель, который будет ссылаться на переменную, необходимо сначала получить адрес этой переменной. Чтобы получить адрес памяти переменной (её расположение в памяти), нужно использовать знак & перед именем переменной. Это позволяет узнать адрес ячейки памяти, в которой хранится значение переменной. Эта операция называется — операция взятия адреса и выглядит вот так:
В строке 3 использовалась операция взятия адреса, мы взяли адрес переменной var и присвоили его указателю ptrVar . Давайте рассмотрим программу, которая наглядно покажет всю мощь указателей. Итак, вот исходник:
Результат работы программы:
В строке 10, printf() выводит значение, хранящееся в переменной var . Почему так происходит? Что ж, давайте посмотрим на код. В строке 5, мы объявили переменную var типа int . В строке 6 — указатель ptrVar на целое значение. Затем указателю ptrVar присвоили адрес переменной var , для этого мы воспользовались оператором присвоения адреса. Затем пользователь вводит номер, который сохраняется в переменную var , помните, что это то же самое место, на которое указывает ptrVar . В самом деле, так как мы используем амперсанд чтобы присвоить значение переменной var в функции scanf() , должно быть понятно, что scanf() инициализирует переменную var через адрес. На этот же адрес указывает указатель ptrVar .
Затем, в строке 10, выполняется операция «разыменования» — *ptrVar . Программа, через указатель ptrVar , считывает адрес, который хранится в указателе, по адресу попадает в нужную ячейку памяти, и возвращает значение, которое там хранится.
Обратите внимание, что в приведенном выше примере, перед тем как использовать указатель, он сначала инициализируется, это нужно для того, чтобы указатель ссылался на определенный адрес памяти. Если бы мы начали использовать указатель так и не инициализировав его, он бы ссылался на какой угодно участок памяти. И это могло бы привести к крайне неприятным последствиям. Например, операционная система, вероятно, помешает вашей программе получить доступ к неизвестному участку памяти, так как ОС знает, что в вашей программе не выполняется инициализация указателя. В основном это просто приводит к краху программы.
Если бы такие приемчики были позволены в ОС, вы могли бы получить доступ к любому участку памяти. А это значит, что для любой запущенной программы вы могли бы внести свои изменения, например, если у вас открыт документ в Word, вы могли бы изменить любой текст программно. К счастью, Windows и другие современные операционные системы остановит вас от доступа к этой памяти и преждевременно закроют вашу программу.
Поэтому, сразу запоминаем, чтобы избежать сбоя в работе вашей программы, вы всегда должны инициализировать указатели, прежде чем использовать их.
Зачем нужны указатели в C++?
Как правило, многие программисты понимают разницу между объектами и указателями на объекты. Тем не менее не всегда ясно, в пользу какого из вышеперечисленных способов обращения делать выбор. Попробуем ответить на этот вопрос.
Однажды на StackOverflow прозвучал вопрос следующего содержания: «Why should I use a pointer rather than the object itself?» — «Почему я должен использовать указатель, а не сам объект?»
И действительно, некоторые программисты применяют указатели на объекты чаще, чем сами объекты, используя такую конструкцию:
Также и с методами, почему вместо этого:
следует писать это:
Разработчик, который задал вопрос, предположил, что это даёт выигрыш в скорости, ведь мы обращаемся напрямую к памяти. К слову, он перешёл в C++-программирование с Java. Что же, посмотрим, что ему ответили.
Ответ
В языке программирования Java указатели в явном виде не используются, то есть разработчик не может обратиться в коде к объекту через указатель на него. Но на деле все типы в Java, за исключением базовых, являются ссылочными, ведь обращение к ним осуществляется по ссылке, хотя явно передать по ссылке параметр нельзя. Также следует заметить, что new в C++, в Java и C# — разные вещи.
Чтобы понять, что же такое указатели в C++, рассмотрим два похожих фрагмента кода. Java:
Эквивалентный код на C++:
Но это – совсем другая вещь (C++):
Но будет ли выигрыш в скорости, если мы будем обращаться к памяти напрямую?
Оказывается, нет. Дело в том, что работа с указателями оформлена в виде кучи, а работа с объектами представляет собой стек — более простую и быструю структуру. Таким образом, в одном вопросе мы получили два: 1. Когда лучше использовать динамическое распределение памяти? 2. Когда лучше использовать указатели?
Конечно, всегда желательно выбирать для работы наиболее подходящий инструмент. Но почти всегда существует реализация лучше, чем с применением ручного динамического распределения (dynamicallocation) и/или «сырых» указателей.
Динамическое распределение
Формулировка вопроса подразумевает 2 способа создания объекта. Главное различие — срок их жизни (storageduration) в памяти программы.
Применяя ObjectmyObject; , разработчик полагается на автоопределение срока жизни, то есть объект уничтожится сразу же после выхода из его области видимости. При этом Object *myObject = newObject; сохраняет жизнь объекту до того самого момента, пока разработчик вручную не удалит его из памяти с помощью команды delete. Применяйте последний вариант лишь тогда, когда это на самом деле надо. А значит, всегда выбирайте автоматическое определение срока хранения объекта, если, конечно, это возможно.
Что касается принудительного установления срока жизни, то оно используется в следующих случаях: • надо, чтобы объект существовал и после выхода из области его видимости. Мы говорим об именно этом объекте и именно в этой области памяти, а не про его копию. Если же для вас это не является принципиальным (а чаще всего это так), следует положиться на автоопределение срока жизни; • надо применять много памяти, которая может переполнить стек. В принципе, с этой проблемой сталкиваются редко, но, бывает, приходится решать и такую задачу; • точно не известен размер массива, который придётся использовать. Вы должны знать, что в C++ массивы имеют фиксированный размер при определении. Это иногда вызывает проблемы, к примеру, во время считывания пользовательского ввода. Указатель же определяет лишь тот участок в памяти, куда записывается начало массива.
Когда применение динамического распределения необходимо, стоит инкапсулировать его посредством умного указателя (о таких указателях мы уже писали) или иного типа, поддерживающего концепцию “Получение ресурса есть инициализация”. Умными указателями, например, являются std::unique_ptr и std::shared_ptr .
Указатели
Иногда применение указателей оправдано не только из-за динамического распределения памяти. Однако помните, что практически всегда существует альтернативный путь, не предполагающий использование указателей — его и следует выбрать. То есть, если особая необходимость использовать указатели отсутствует, всегда выбирайте альтернативу.
Однако рассмотрим случаи, когда применение указателей оправдано: • ссылочная семантика. Иногда нужно обратиться к объекту вне зависимости от того, каким образом под него распределена память, если вы желаете обратиться в функции именно к этому объекту, а не к его копии. То есть речь идёт о случае, когда надо реализовать передачу по ссылке. Но, опять же, чаще всего тут достаточно использовать не указатель, а именно ссылку, ведь как раз для этого и созданы ссылки. Но если есть возможность обратиться к копии объекта, то и ссылку, соответственно, использовать нет необходимости (однако учтите, что копирование объекта является дорогой операцией); • полиморфизм. С помощью ссылки либо указателя возможен вызов функций в рамках полиморфизма. Но и тут использование ссылок предпочтительнее; • необязательный объект. Тут можно применять nullptr, дабы указать, что объект опущен. Но если это аргумент функции, сделайте реализацию с аргументами по умолчанию либо перегрузкой. С другой стороны, вы можете использовать тип, инкапсулирующий такое поведение, допустим, boost::optional (измененный в C++14 std::optional); • повышение скорости компиляции. Иногда нужно разделить единицы компиляции (compilationunits). Один из эффективных вариантов использования указателей — предварительная декларация. В результате вы получите возможность разнести единицы компиляции, что обычно положительно сказывается на ускорении времени компиляции. • взаимодействие с С-подобной библиотекой. Здесь придётся задействовать сырые указатели, освобождение памяти из-под которых вы выполняете в самый последний момент. Сырой указатель можно получить из умного, используя операцию get. Если библиотека использует память, которая потом должна освобождаться вручную, можно оформить в умном указателе деструктор.
Указатели в C абстрактнее, чем может показаться
Если мы скомпилируем этот код GCC с уровнем оптимизации 1 и запустим программу под Linux x86-64, она напечатает следующее:
Обратите внимание, что указатели p и q ссылаются на один и тот же адрес. Однако результат выражения p == q есть false, и это на первый взгляд кажется странным. Разве два указателя на один и тот же адрес не должны быть равны?
Объект — это область хранения данных в среде выполнения, содержимое которой может использоваться для представления значений
Когда на пути встаёт компилятор
С похожей проблемой в 2016 году столкнулись программисты под Linux. Рассмотрим следующий код:
Символами _start и _end задают границы области памяти. Поскольку они вынесены во внешний файл, компилятор не знает, как на самом деле массивы расположены в памяти. По этой причине он должен здесь проявить осторожность и исходить из предположения, что они следуют в адресном пространстве друг за другом. Однако GCC компилирует условие цикла так, что оно всегда верно, из-за чего цикл становится бесконечным. Эта проблема описана вот в этом посте на LKML — там используется похожий фрагмент кода. Кажется, в данном случае авторы GCC все-таки учли замечания и изменили поведение компилятора. По крайней мере я не смог воспроизвести эту ошибку в версии GCC 7.3.1 под Linux x86_64.
Разгадка — в отчёте об ошибке #260?
Наш случай может прояснить отчёт об ошибке #260. Он больше касается неопределённых значений, однако в нём можно найти любопытный комментарий от комитета:
Реализации компиляторов [. ] могут также различать указатели, полученные из разных объектов, даже если эти указатели имеют одинаковый набор битов.
Если понимать этот комментарий буквально, то тогда логично, что результат выражения p == q есть «ложь», так как p и q получены из разных объектов, никак не связанных между собой. Похоже, мы всё ближе подбираемся к истине — или нет? До сих пор мы имели дело с операторами равенства, а как насчёт операторов отношения?
Окончательная разгадка — в операторах отношения?
Здесь указатели p и q ссылаются на два разных объекта, которые не связаны между собой. Поэтому результат их сравнения не определён. А вот в следующем примере:
указатели p и q ссылаются на один и тот же объект и, следовательно, связаны между собой. Значит, их можно сравнить — если только malloc не вернёт нулевое значение.
Резюме
Стандарт C11 недостаточно строго описывает сравнение указателей. Наиболее проблемным моментом, с которым мы столкнулись, стал пункт 6 § 6.5.9, где явно разрешено сравнивать два указателя, ссылающиеся на два разных массива. Это противоречит комментарию из отчёта об ошибке #260. Однако там речь идёт о неопределённых значениях, и я не хотел бы строить свои рассуждения на основании одного лишь этого комментария и толковать его в другом контексте. При сравнении указателей операторы отношения определяются несколько иначе, чем операторы равенства — а именно, операторы отношения определены, только если оба указателя получены из одного и того же объекта.
Если отвлечься от текста стандарта и задаться вопросом, можно ли сравнивать два указателя, полученных из двух различных объектов, то в любом случае ответ, скорее всего, будет «нет». Пример в начале статьи демонстрирует скорее теоретическую проблему. Поскольку переменные a и b имеют автоматическую продолжительность хранения, наши предположения об их размещении в памяти будут ненадёжными. В отдельных случаях мы можем угадать, но совершенно очевидно, что такой код не получится безопасно портировать, и узнать смысл программы можно, только скомпилировав и запустив или деассемблировав код, а это противоречит любой серьёзной парадигме программирования.
Однако в целом я не удовлетворён формулировками в стандарте C11, и так как уже несколько человек столкнулось с этой проблемой, актуальным остаётся вопрос: почему бы не сформулировать правила яснее?
Дополнение
Указатели на позицию за последним элементом массива
Что касается правила о сравнении и адресной арифметике указателей на позицию за последним элементом массива, сплошь и рядом можно найти исключения из него. Предположим, что стандарт не разрешал бы сравнивать два указателя, полученные из одного и того же массива, при том что хотя бы один из них ссылается на позицию за концом массива. Тогда следующий код не работал бы:
С помощью цикла мы обходим весь массив x, состоящий из 64 элементов, т.е. тело цикла должно выполниться ровно 64 раза. Но на самом деле условие проверяется 65 раз — на один раз больше, чем число элементов в массиве. В первые 64 итерации указатель i всегда ссылается внутрь массива x, тогда как выражение &x[num] всегда указывает на позицию за последним элементом массива. На 65-й итерации указатель i будет также ссылаться на позицию за концом массива x, из-за чего условие цикла станет ложным. Это удобный способ обойти весь массив, при этом он опирается на исключение из правила о неопределённости поведения при сравнении таких указателей. Обратите внимание, что стандарт описывает поведение лишь при сравнении указателей; их разыменование — это отдельная тема.
Можно ли изменить наш пример так, чтобы на позицию за последним элементом массива x не ссылался бы ни один указатель? Можно, но это будет сложнее. Придётся изменить условие цикла и запретить инкремент переменной i на последней итерации.
В этом коде полно технических тонкостей, возня с которыми отвлекает от главной задачи. Кроме того, в теле цикла появилась дополнительная ветка. Так что я считаю разумным, что стандарт разрешает исключения при сравнении указателей на позицию за последним элементом массива.
Примечание команды PVS-Studio
При разработке анализатора кода PVS-Studio нам приходится иногда разбираться с тонкими моментами, чтобы сделать диагностики более точными или чтобы давать подробные консультации нашим клиентам. Эта статья показалась нам интересной, так как затрагивает вопросы, в которых мы сами до конца не чувствуем себя уверенными. Поэтому мы попросили у автора выложить её перевод. Надеемся, так с ней познакомится больше C и C++ программистов и поймут, что не всё так просто и что, когда вдруг анализатор выдаёт странное сообщение, не стоит сразу спешить считать его ложным срабатыванием :).