C: указатели – подробный разбор

Автор: | 22/05/2016
 

C_logoПеревод с небольшими дополнениями, уточнениями и примерами.

Оригинал в посте C Pointers Explained, Really.

>>><<<

Когда я учился в колледже – мой друг пожаловался, что ему сложно понять синтаксис и использование указателей при программировании на С.

Для примера он привёл код вида “*x=**p++“, говоря что он уродливый и сложный для чтения, в нём слишком много операторов, наложенных друг на друга, что усложняет понимает того, что код должен выполнить.

Я написал небольшое пояснение для наших студентов и получил много хороших отзывов. Некоторые люди говорили, что они программируют на С много лет – но только прочтя мой пост они поняли указатели в С.

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

Допустим, у нас имеется код:

...
main() {
    int n;
    int *p;
    ...

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

           :
Address:   :
        |-----|
  0x5100|     |   n is an integer, one machine word big
        |-----|
  0x5104|     |   p is a pointer, also one word big
        |-----|
  0x5108|     |   other unused memory
        |-----|
           :
           :

Давайте зададим значения этим переменным.

Например, n будет иметь значение 151:

 n = 151;

И создадим указатель p, который указывает на целочисленное n:

p = &n;

Т.е., мы говорим, что “значение переменной p – это адрес переменной n“:

             :
Address:     :     Value at that address:
          |----|
  0x5100  | 151|  n
          |----|
  0x5104  |5100|  p
          |----|
  0x5108  |   ?|
          |----|
             :
             :

Теперь – я могу напечатать двумя способами:

printf("n is %d.\n", n);
printf("n is %d.\n", *p);

Весь код будет выглядеть так:

#include <stdio.h>

main() {
    int n;
    int *p;

    n = 151;
    p = &n;

    printf("n is %d.\n", n);
    printf("n is %d.\n", *p);

    return 0;
}

Результат его работы:

$ ./pointers_explained 
n is 151.
n is 151.

Оператор “*” в нашем коде говорит: “Дайте мне объект, расположенный по такому-то адресу“. Тип объекта – такой же, как же тип указателя, который мы определили при его объявлении (int *p;). Так как мы определили тип указателя как целочисленный (integer) – то С будет предполагать, что объект, на который он указывает так же является целочисленным.

Теперь – я хочу вывести адрес памяти переменной n:

...
    printf("n is located at $%x.\n", &n);
    printf("n is located at $%x.\n", p);
...
$ ./pointers_explained 
n is located at $6ef29314.
n is located at $6ef29314.

Оператор “&” говорит “Дайте мне адрес, с которого начинается такой-то объект“. В данном случае это шеснадцатиричное значение 6ef29314 (0x5104 в примере карты памяти выше).

Обратите внимание, что значение переменной p – это адрес.

А как на счёт адреса самой переменной p? Конечно, как и любая другая переменная – у неё есть свой адрес, и получить его можно так:

printf("p is located at $%x.\n", &p);
$ ./pointers_explained 
n is located at $81b22104.
n is located at $81b22104.
p is located at $81b22108.

Мы получаем адрес переменной с помощью оператора &.

Усложним пример.

main() {

    char name[] = "Bill";
    char *p;
    int *q;

    return 0;
}

Теперь у нас имеется массив name[], с которым можно поиграть. Вот как будет выглядеть память теперь:

        |---|
 0x5100 |'B'|  "name" is an address constant that has value hex 5100
        |---|
 0x5101 |'i'|  char: 1 byte
        |---|
 0x5102 |'l'|  char: 1 byte
        |---|
 0x5103 |'l'|  char: 1 byte
        |---|
 0x5104 |\0 |  char: 1 byte
        |---|
 0x5105 |   |  p is a pointer: 1 word
        |---|
 0x5109 |   |  q is a pointer: 1 word
        |---|

Установим p как указатель на массив name[]:

p = name;

Тепреь значение p0х5100. Мы можем использовать оператор разыменовывания “*” и получить символ B, который находится по адресу 0х5100:

main() {

    char name[] = "Bill";
    char *p;
    int *q;

    p = name;
    printf("%c\n", *p);

    return 0;
}
$ ./pointers_explained 
B

А что произойдёт, если мы выполним инкремент p?

...
    p = name;
    printf("%c\n", *p);
    ++p;
    printf("%c\n", *p);
...

Значение указателя p будет увеличено на 1, а его значение станет равным 0х5101. Очень просто, правда?

$ ./pointers_explained 
B
i

А теперь – давайте сделаем что-то безответственное:

q = name;

Но ведь q имеет целочисленный (int *q;) тип!

Если выполним разыменование q – мы получим слово в 4 байта, т.к. тип int как правило равен 4 байтам:

...
    int integerType;
    printf("Size of int: %ld bytes\n",sizeof(integerType));
...
$ ./pointers_explained 
Size of int: 4 bytes

Которое начинается с адреса “name” (0x5100), которое будет транслировано в челочисленное значение. ‘B‘, ‘i‘, ‘l‘, ‘l‘ в целочисленном представлении вернёт нам некое длинное число, в зависимости от машины:

...
    q = name;
    printf("%d\n", *q);
...
$ ./pointers_explained 
1819044162

Число 1819044162 тут – это десятичное представление значения 0x6c6c6942, которое получено из шеснадцатиричного представления символов:

     B   i   l   l
name 42  69  6c  6c

Которое можно увидеть, если добавить printf %x (hexadecimal представление):

...
    q = name;
    printf("%d\n", *q);
    printf("%x\n", *q);
...

Вернёт нам:

$ ./pointers_explained 
1819044162
6c6c6942

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

Что бы лучше продемонстрировать это – возьмём ещё один пример, и выполним инкремент указателя c:

...
    char long_name[] = "BillLongName";
    int *c;
    c = long_name;
    printf("%x\n", *c);
    ++c;
    printf("%x\n", *c);
...
$ ./pointers_explained 
6c6c6942
676e6f4c

После инкремента целочисленного указателя c – он указывает на следующее слово в памяти:

          g  n  o  L
long_name 67 6e 6f 4c

Всё должно выглядеть достаточно просто, если вспомнить предыдущий параграф с указателем p. Значение указателя c было увеличено на размер объекта, на который направлен наш указатель. Так как и указатель имеет тип integer, следовательно – предполагается что объект так же явлется типом int. Таким образом инкремент “передвигает” указатель на следующие 4 байта (рамер int).

Возьмём следущий пример:

int main() {
    int n;
    n = 151;
    f(n);
}

int f(int x) {
    printf("%d\n", x); 
}

Это простая программа, в которой происходит передача integer объекта “по значению” (by value) – n передаётся в функцию f().

Т.е. – значение переменной n копируется в новую переменную x:

        |---|
 0x5100 |151|  n is an integer
        |---|
 0x5104 |151|  x is another integer
        |---|

Когда мы говорим о переменной x – мы используем значение из адреса 0x5104, и мы можем делать с ним что угодно, при этом не затрагивая число по адресу 0x5100.

Но что, если мы хотим в фукнции f() изменить значение переменной, а потом использовать его в функции main()? В C это можно сделать, передав переменную “по ссылке” (by reference):

int main() {
    int n;
    n = 151;
    f(&n);
    printf("After F(): %d\n", n);
}

int f(int *x) {
    printf("%d\n", *x);
    *x = 451;
}

Мы передаём адрес переменной n (в виде &n), а в функции f() x объявляется как указатель на n.

Мы всё ещё передаём некоторое значение, но теперь это значение – адрес, а не число:

        |----|
 0x5100 | 151|  n is an integer
        |----|
 0x5104 |5100|  x is a pointer to int
        |----|

x всё ещё указывает на адрес 0x5100, но мы изменили значение в этом участке памяти (*x = 451;).

А теперь – вернёмся к тому, с чего начинали: уродливому и нечитабельному выражению “*x=**p++“.

Рассмотрим карту памяти:

        |----|  слово в памяти со значением 0, без переменной 
 0x5100 |   0|
        |----|
 0x5104 |  12|  тут хранится значение, слово в памяти, тоже без переменной
        |----|
 0x5108 |5104|  целочисленный указатель на предыдущее слово
        |----|
 0x511c |5108|  тут переменная p, указатель на целочисленный указатель
        |----|
 0x5120 |5100|  тут переменная x, указатель, которая указывает на адрес со значением 0
        |----|

Для начала – давайте посмотрим на x и p:

  • int *x; – указатель на integer;
  • int **p;  – указатель на указатель на указатель; “подчинённый” указатель указывает на int.

Вы знаете, что такое *x – это значит “тут значение из 0x5100“.

И вы знаете, что *p – это указатель на значение по адресу 0x5108.

Теперь выражение “x = **p” можно прочитать как “Целочисленное по адресу 0х5100, которое принимает значение из адреса 0x5104“.

Расмотрим “указатель на указатель” в таком примере:

int main () {

    char a;
    char *b;
    char **c;

    a = 'A';
    b = &a;
    c = &b;

    printf("A value: %c\n", a);
    printf("A adress: %p\n", (void*) &a);
    printf("B value: %p\n", b);
    printf("B adress: %p\n", (void*) &b);
    printf("C value: %p\n", c);
    printf("C adress: %p\n", (void*) &c);
}    
$ ./pointers_explained 
A value: A
A adress: 0x7ffccd4ea9cf
B value: 0x7ffccd4ea9cf
B adress: 0x7ffccd4ea9d0
C value: 0x7ffccd4ea9d0
C adress: 0x7ffccd4ea9d8

Но возвращаясь к нашему коду, "*x=**p++” – что тут значит “**p++“? Он будет эквивалентен коду “*( *( p++ ) )“, т.е. – “Указатель на указатель на ineteger, а после выполнения действия – увеличить его значение на 1“.

Дополним пример выше:

...
    printf("\nAfter *b=**c++;:\n\n");
    *b=**c++;
    printf("B value: %p\n", c);
    printf("B adress: %p\n", (void*) &c);
    printf("C value: %p\n", c);
    printf("C adress: %p\n", (void*) &c);
...

Результат:

$ ./pointers_explained 
A value: A
A adress: 0x7ffd01a1c08f

B value: 0x7ffd01a1c08f
B adress: 0x7ffd01a1c090
C value: 0x7ffd01a1c090
C adress: 0x7ffd01a1c098

After *b=**c++;:

B value: 0x7ffd01a1c098
B adress: 0x7ffd01a1c098
C value: 0x7ffd01a1c098
C adress: 0x7ffd01a1c098

Оригинал тут>>>.