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

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