Заметки по языкам C и C++.
<  0  1  2  3  >  
Заметка номер 2 (об указателях)
Тип указателя? Что это?
Обычно на вопрос о том, что такое указатель, отвечают, что это просто адрес, переменной (или структуры) в памяти. Все просто - это адрес. Тогда зачем указывать тип адреса. Не все ли равно? Адрес это только число и все. Все однако не так просто. Когда мы определяем указатель, мы определяем его для переменной конкретного типа. например так.
int * i;
int p=33000;
i=&p;
Для компилятора это указатель на целое число. Длина целого числа типа int во всех основных моделях памяти составляет 4 байта. Поэтому, когда мы произведем операцию разыменования, то компилятору будет понятно, что *i - это целое четырехбайтовое число. И если далее по тексту вы запишите
k=*i;
где k имеет тип short int, т.е. представляет собой 2-х байтовое число, то некоторые компиляторы вероятно предупредят вас о возможной ошибке. И это правильно, здесь может произойти потеря значения - присвоение с искажением. Если компилятор не предупреждает вас о такой ситуации, то вероятнее всего у него есть опция, которую можно установить, чтобы предупреждение появлялось. Такого рода ошибки, связанные с некоторектным приведением типов, искать очень трудно. Она может проявить себя, только при наступлении определенных условий - значение *i переступит двухбайтовый порог. Таким образом, тип указателя нужен прежде всего для того, чтобы чтобы отсечь хотя бы часть ошибок, которые могут возникнуть при так называемом приведении типов. В частности, в выше приведенном примере заведомо очевидно, что переменной k будет присовено не верное значение (попробуйте, не прибегая к выполнению программы сосчитать, какое).
Следует отметить еще один важный аспект. С указателями можно производить действия. И вот здесь компилятор будет четко понимать, с каким типом указателя имеет дело. Если мы имеем указатель на число типа int, то прибавление к указателю 1 означает не увеличение адреса на единицу, а увеличение его на 4, т.е. переход к адресу, где может находится другая переменная. Это особенно удобно, когда имеешь дело с массивами (об этом я поговорю отдельно). Другими словами, компилятор помогает нам правильно обращаться с указателями и действует он на основе типа указателя.
В языке C также имеется пустой тип данных void, можно задавать указатель на пустой тип: void * p. Это значит, что компилятор не знает тип этого указателя и во всех выражениях следует конкретизировать этот тип. Например так.
void * p;
p=malloc(4);
int * k = (int *)p;
Когда мы создаем указатель, скажем так int * k;, то он не инициализирован. Т.е. его значение не определено и может быть чем угодно. Для программистов это головная боль. Вот почему компилятор, обычно предупреждает, когда в выражении вы пытаетесь использовать не инициализированный указатель (как и инициализированную переменную, впрочем). Есть специальное значение NULL, которое можно присваивать указателю. Это значение, которое говорит нам, что указатель еще никуда не направлен. Это очень удобно. Вы всегда можете проверить указатель, не равен ли он NULL: так if(k!=NULL) или просто if(k). Можно взять себе за правило, при определении указателей, если их значение еще определить нельзя - присваивать им значение NULL.
Указатель как параметр
Вопрос об использовании указателей в качестве параметров очень интересен. В качестве параметров, передаваемых в функцию можно использовать числовые константы или переменные. Но посколку указатель это также переменная, то, соответственно в качестве параметра в функцию можно передавать и его. Рассмотрим следующую функцию
int comp(int a, int b){
if(a>b){
return 1;
}else{
if(b>a){
return 0;
}else{
return-1;
}
}
}
Функция вызывается путем указания имени и параметров, например, так comp(3,4), или comp(x,y). Т.е. в качестве параметров можно использовать и числовые константы и переменные. Конечно, если у нас i и j это указатели на переменные типа int, то можно вызвать функцию и так тоже comp(*i,*j).
В самой функции параметры a и b ведут себя, как обычные переменные. Мы можем присваивать и хранить в них значения, отличные от тех, которые были переданы при вызове функции. Однако, если к примеру, мы вызвали функцию так comp(x,y), используя переменные x и y, то никакие изменения параметров a и b не повлияют на значения x и y.
Перепишем теперь нашу функцию comp следующим образом
int comp(int *a, int *b){
if(*a>*b){
return 1;
}else{
if(*b>*a) {
return 0;
}else{
return-1;
}
}
}
Мы видим, что теперь в качестве параметров должны передаваться указатели на переменные типа int. И вызов функции осуществляется уже так comp(&x,&y), где x и y это переменные типа int. И в этом случае, если мы изменим содержимое параметра, точнее содержимое, на которое указывает параметр, то изменим тем самым и значение переменной. Например, написав в функции, *a=30, мы тем самым изменим и значение переменной x на 30.
Описанный выше прием дает поистине неограниченные возможности по передаче данных в функцию и обратно из нее. Действительно, мы можем передать указатель на структуру любой сложности или любой массив. Функция может использовать эти данные или вернуть посредством структуры или массива данные в вызывающую часть программы.
И это было наверное все о об указателях в качестве параметров. Только вот C++, вобрав в себя возможности C, добавила, на мой взгляд, избыточную возможность передачу данных в функцию по ссылке. Выглядит все слудеющим образом.
int comp(int &a, int &b){
if(a>b){
return 1;
}else{
if(b>a) {
return 0;
}else{
return-1;
}
}
}
Вызов такой функции осуществляется так comp(x,y), но при этом в функцию передается ссылка на переменную, фактически тот же указатель. Изменяя значение параметров a и b фактически означает изменение переменных x и y.
Указатель и массивы
Массивы в языке C вообще строятся на основе указателей, хотя имеется и форма доступа, аналогичная, например, паскалевской. В классическом C задать массив это а) задать указатель на начло массива; б) зарезервировать непрерывную область данных, где будут хранится элементы массива. Например запись int M[10]; задает массив, состоящий из 10 элементов типа int. При этом M - указатель на начало массива или точнее на первый (по нумерации он нулевой) его элемент. Другими словами M и &M[0] обозначают один и тот адрес памяти.
Замечание
Для проверки данного утверждения используйте библиотечную функцию printf: printf("%p\n%p\n",M,&M[0]);. Результат - адреса должны совпасть.
Для обращения к элементам массива можно использовать как M[i] так и *(M+i). И вот здесь как раз важную роль будет играть тип массива, точнее тип указателя M. Компилятор будет автоматически масштабировать результирующий адрес. Другими словами M+4 будет указывать на элемент массива, четвертый по счету, а не на четвертый байт. Во всем остальном массив в классическом C это просто область памяти, выход за пределы которой может привести к краху программы. Никакого контроля во время компиляции за подобного рода ошибками не производится.
Утечка памяти и другие ошибки
Утечка памяти или по английски leaking memory, типичная ошибка, встречающаяся в программах на языке C. Вообще на языке C очень просто использовать динамическую память (динамические переменные). Для этого есть набор библиотечных функций. Чаще всего используется стандартная функция malloc. Для примера приведу вот такую простую функцию.
void f1() {
int a;
int * m = (int*) malloc(4);
*m=a=345;
printf("%d %d\n",*m,a);
return;
}
Функция не слишком интересна с алгоритмической точки зрения, зато здесь много чего можно сказать по поводу двух переменных m и a. a типичная локальная переменная, которая хранится в стеке. По выходу из функции, стек возвращается в исходное состояние и переменная перестает существовать. Заметим, что и переменная m также является локальной и также по выходу из функции, ее значение пропадает. В этой переменной хранится указатель. Из фрагмента видим, что что по началу этот указатель не определен. Но затем ему присваивается адрес выделенной, с помощью библиотечной функции malloc(), области памяти.
Замечание
Конечно, значения, которые хранятся в стеке остаются там, по окончанию работы функции. Никто их там не "чистит". Анализируя стек, мы могли бы узнать, что делала функция. Но это отдельный разговор, касающийся анализа работы программы, ее отладки. Содержимое стека, однако, меняется, при повторном вызове этой или других функций.
Однако, если память, которая отводилась в стеке для локальных переменных освобождается, то память, выделенная функцией malloc() никуда не делась, она по прежнему существует. Но беда в том, что адрес этого блока оказался утерянным, так как переменной m больше нет. При повторном вызове функции, будет выделен новый блок памяти и снова, при выходе, он сохранится в общей памяти, которая выделена приложению. Получается, что память утекает. Вот это и есть та самая утечка памяти. А чтобы она не утекала перед выходом из процедуры следует освободить выделенную память. Например, вот так free(m) - память свободна, да и указатель нам больше не нужен.
Еще одна типичная ошибка также связана с локальными указателями. Для начала рассмотрим следующий пример.
...
int f1(int g) {
int a;
a=g;
return a;
}
...
int main() {
int c = f1(123);
int d = f1(222);
printf("%d %d\n",c,d);
return 0;
}
Интересно, что будет выведено на консоль? Разумеется два числа: 123 и 222. Ответ правильный, но совсем не тривиальный. Действительно, функция возвращает значение переменной, которая после выхода из функции перестает существовать. Таким образом должен существовать механизм передачи значения переменной, без сохранения самой переменной. На низком уровне это регистры микропроцессора. Для процессоров Intel это регистр RAX (или EAX для 32 - битной модели). Команда return a передает значение переменной в регистр процессора. Таким образом значение переменной сохраняется, тогда как самой переменной уже нет.
Рассмотрим теперь следующий пример, несколько похожий на предыдущий.
...
int * f1(int g) {
int a;
a=g;
return &a;
}
...
int main() {
int *c = f1(123);
int *d = f1(222);
printf("%d %d\n",*c,*d);
return 0;
}
Ну, похожий, да не совсем. Изменился тип функции. Она теперь возвращает не значение, а указатель (хотя указатель - тоже значение :)). И вот тут начинается самое интересное. Функция возвращает указатель на переменную, которой уже нет. Конечно ее нет, но значение в стеке временно осталось. И, следовательно, если мы запомним значение сразу после вызова функции, то получим правильный результат. Однако в нашем случае это совсем не так. Мы запомнили не значение, а указатель, который потом используем для вывода значения. А вторичный вызов функции, дожен по идее затереть старые значения в стеке. Таким образом, гарантированно правильно будет выдаваться только значение *d.
Все однако не так просто. Я не удивлюсь, если ваша программа вместо двух одинаковых чисел 222 и 222 выдаст, на первый взгляд "правильный" результат 123 и 222. Этот результат страшен тем, что вводит в заблуждение, маскирует явную ошибку. Причина тому следующая. Современные компиляторы не плохо оптимизируют код. Один из способов оптимизации заключается в том, что фрагменты кода могут заменяться результатом работы этого фрагмента, если он просчитывается. Что касается функций, то часто оптимизаторы подставляют код функции непосредственно в то место, где функция вызывается. И тот и другой метод оптимизации может привести к выше указанному результату. Уберите все оптимизационные опции и вы получите два одинаковых числа 222 и 222. Что будет говорить нам о том, что наша программа содержит ошибку. Не возвращайте в функции указатели на локальные переменные!!!
Указатель на функцию
Указатель на функцию - очень интересный феномен. В данном случае мы имеем дело не с адресом данного, а с адресом кода. Как работает указатель на функцию можно увидеть из следующего фрагмента. В нем переменная f является по определению указателем на функцию. В дальнейшем она используется в качестве параметра для функции func3. В зависимости от значения этого параметра, результат выполнения функции различен. Другими словами функция вызывает другую функцию адрес которой получает в качестве параметра. Это очень мощное и гибкое средство. Ведь только вдумайтесь: мы в качестве параметра передаем не данное, а действие. Это совершенно новые и очень мощные возможности.
int func1(int a, int b){
return a*a+b*b;
}
int func2(int a, int b){
return a*a-b*b;
}
void func3(int i, int j, int (*p)(int,int)){
printf("%d\n",(*p)(i,j));
}
int main() {
int (*f)(int, int);
f = func1;
func3(10,20,f);
f= func2;
func3(10,20,f);
return 0;
}
Указатель на функцию превращает ее в переменную и позволяет более гибко решить проблему вызова "своих" функций для различных данных. Попробуйте в качестве упражнения написать программу, где используется массив указателей на различные функции, а вызов этих функций осуществляется на основе выбора индекса в массиве.
Свойства переменных
Данные, которыми оперируют программы, называются переменными. Название подчеркивает, что содержимое их может меняться. Другими словами их можно читать и в них можно писать. Переменные бывают простыми - это числовые переменные, и переменные со сложным внутренним устройством, например, массивы, строки, структуры. Но в данном случае нас интересует более общие свойства переменных, по отношению к исполняемой программе. Таких свойств три: время жизни, область видимости, область действия. В языке C время жизни переменной определяется тем блоком, в котором эта переменная определена. Переменная, которая определена вне всяких блоков, считается глобальной. Глобальные переменные живут столько же, сколько и сама работающая программа. Переменные, которые определены в блоке, появляются с момента объявления, и до тех пор, пока ни происходит выход из этого блока. Такие переменные называются локальными. Чаще всего локальные переменные определяются в рамках функции. Соответственно такие переменные уничтожаются после окончания работы функции.
Вопрос с областью видимости и областью действия (или доступности) более интересен. В общем случае эти две области не совпадают. Для локальной переменной вроде бы все просто: переменная видна в том блоке, где определена, соотвественно в этом блоке ей можно пользоваться (область действия). Но в случае глобальной переменной все не так однозначно. Область видимости глобальной переменной весь программный код, даже если код состоит из нескольких файлов (модулей). Но вот доступ к глобальной переменной может загораживать локальная переменная.
#include <stdio.h>
int a;

void f1() {
int a;
for(a=1; a<10; a++){
int c=10;
c=a+c;
}
return;
};
int main() {
a=10;
f1();
printf("%d\n",a);
return 0;
}
Программа предельно проста. Локальная переменная с именем a загораживает доступ к глобальной переменной с тем же именем в пределах функции f1. В языке C (но не в C++) выход один: внимательно подбирать имена переменных. Замечу, кстати, что многие программисты на имена переменных вообще внимание не обращают. Стоит, наверное, посвятить этому вопросы какую то часть моих рассуждений.
Но C++ сильно изменил возможности C в плане видимости и действия переменных. Прежде всего появился оператор :: - оператор разрешения области видимости. В вышеприведенном примере для того, чтобы обратиться к глобальной переменной a в функции f1, достаточно вместо a использовать ::a. Впрочем, этим не исчерпывается возможность этого оператора. Появилась такая удобная штука, как пространство имен.
Модификатор static C++ привносит довольно неожиданные свойства локальным переменным. Если применить его к локальной переменной, то время жизни этой переменной начнется с момента первого ее объявления и будет продолжаться до окончания работы программы. Не мудрствуя лукаво перейдем сразу к примеру - эксперименту.
#include <stdio.h>
int func() {
static int a=1;
a++;
return a;
}
int main() {
printf("%d\n",func());
printf("%d\n",func());
printf("%d\n",func());
printf("%d\n",func());
return 0;
}
Как вы думаете, каков будет результат? Проверьте это сами, друзья мои, это интересно.

<  0  1  2  3  >  
 Письмо      Сайт      Заметки      Страница-портал      Журнал
Пирогов Владислав Юрьевич, Copyright (c), 2013-2015