Лекция 15, массивы указателей

Указатели на указатели

Сразу, без введения, начнём с примера:

int a = 10;
int *pa = &a; // Присваиваем pa указатель на a.
int **ppa = &pa; // Присваиваем ppa указатель на pa.
            // Оператор & делает здесь из типа (int *)
            // тип (int **)

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

int a = 10;
int *pa;
pa = &a;
int **ppa;
ppa = &pa;

Указатели на указатели часто применяются, чтобы изменить внутри функции значение какого-то указателя. Чтобы изменить значение числа, аргумент должен быть указателем на число этого типа, чтобы изменить значение указателя, аргумент должен быть указателем на указатель. (Другим способом изменить указатель является его возврат через значение функции, но, очевидно, таким образом из функции мы можем изменить только один указатель). Все три способа проиллюстрированы в следующем коде:

void set_pointer(int *ptr, int **changed_ptr)
{
    *changed_ptr = ptr;
}

int *return_ptr(int *ptr)
{
    return ptr;
}

int main()
{
    int a;
    int *p1, *p2;
    p1 = &a;


    // Первый способ, непосредственное присвоение
    p2 = p1;

    // Второй способ, установка указателя в самой функции
    set_pointer(p1, &p2);

    // Третий способ, присваивание возвращённого указателя
    p2 = return_ptr(p1);
}

Массивы строк

Если нам нужен массив строк, то один из самых простых способов его реализации – это просто двумерный массив букв:

char str_arr[N_STR][STR_MAX_LEN];

где N_STR и STR_MAX_LEN – две целочисленные константы, объявленные через #define. Однако, после некоторых размышлений, можно прийти к выводу, что данная реализация не является оптимальной из-за неэффективного использования памяти. В самом деле, если нам понадобится хранить несколько длинных строк и очень много коротких, мы будем вынуждены задать константу STR_MAX_LEN достаточно большой, чтобы она вместила длинные строки, при этом STR_MAX_LEN байт будет зарезервировано для каждой строки, включая короткие.

Улучшенная реализация массива строк предполагает использование массива указателей:

char *str_arr[N_STR]; // массив из N_STR указателей на char.

char arr[10] = "dog";
char arr1[10] = "bee\0ant";
str_arr[0] = arr;
str_arr[1] = arr1;
str_arr[2] = arr1 + 5; // или &arr1[5]
str_arr[3] = "fish";

int i;
for(i = 0; i < 4; ++i) {
    printf("%s\n", str_arr[i]);
}

Данный код выведет следующие 4 строки:

dog
bee
ant
fish
_images/str_arr.png

Обратите внимание, что в записи вида str_arr[0] = “fish”; строка в двойных кавычках является строковой константой, которую можно сохранить где-нибудь, но запрещается изменять содержимое строковой константы. В выражении char arr[10] = “dog”; строка в кавычках обозначает начальное содержимое массива, сам же массив вполне изменяем.

Аргументы командной строки

Рассмотрим программу echo, которая при запуске с аргументами командной строки выводит их через пробел. При запуске её в териминале строкой

./echo hello world

она выведет свои аргументы:

hello world

Код программы:

#include <stdio.h>

int main(int argc, char *argv[])
{
    int i;
    // Начинаем с индекса 1, а не 0,
    // так как в argv[0] хранится имя программы.
    for(i = 1; i < argc; ++i) {
        printf("%s", argv[i]);
        if (i < argc - 1) {
            printf(" ");
        } else {
            printf("\n");
        }
    }
    return 0;
}

int argc
(argument count) число аргументов командной строки, включая имя программы.
char *argv[]

(argument values) значения аргументов.

  • argv[0] – имя программы
  • argv[argc - 1] – последний аргумент
  • argv[argc] == NULL – после последнего элемента хранится NULL, чтобы гарантировать ошибку при выходе за границу массива.
gcc proga.c -o echo
./echo hello world

Интерпретатор командной строки получает от пользователя запрос на выполнение программы, разбивает его на аргументы просит операционную систему запустить указанную программу с этими аргументами.

_images/args1.png
argv[i];        // i-я строка
argv[i][j];     // j-ая буква в i-ой строке.

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

./proga   "abc"     "d e f " xyz

При таком запуске программы в argv[1] будет лежать "abc", в argv[2] – строка "d e f ", в argv[3] – строка "xyz".

Отладка

Итак, если в написали некоторую программу или её часть, и она не работает:

  1. Прогоните алгоритм на бумаге вручную.

  2. Используйте отладочную печать

    1. printf("a = %d\n", a);
      

      Этот printf поясняет вам, какое число выводится, чтобы не запутаться, если отладочного вывода много.

    2. Чтобы определить, выполняется ли какой-то код, пишем printf("Выполнилось!\n); в соответствующем месте.

    3. Если есть несколько одинаковых печатей вида a или b, надо пометить их идентификатором:

      printf("#1 a = %d\n", a);
      // ...
      printf("#2 a = %d\n", a);
      // ...
      
    4. Результат логических выражений тоже можно печатать:

      printf("a > b => %d\n", a > b);
      

      Такой код выведет “a > b => 1”, если выражение истинно, и “a > b => 0”, если оно ложно.

  3. Отладка Segmentation Fault.

    1. Основная задача – локализовать ту строку, в которой происходит сегфолт. Делаем это с помощью printf-ов вида 1.b, добавляя их по необходимости, пока не дойдём до конкретной строки.
    2. При отладке Segfault-a надо обязательно ставить \n в конце строки каждого printf-a. В стандартной библиотеке Си для вывода на терминал используется построчная буферизация, символы выводятся на терминал после того, как поступает символ перевода строки. Когда происходит Segmentation Fault, программа завершается аварийно и символы, оставленные в буфере и не выведенные на печать, теряются. Таким образом, может создастся ложное ощущение, что printf не выполнился, и искать ошибку выше по коду, а не ниже.

Макрос assert

assert – по английски “утверждать”, соответствующая конструкция предназначена для проверки утверждений, которые должны быть истинны при правильном выполнении программы. Другими словами, с помощью assert программист может проверить сам себя.

#include <assert.h>

// ...
assert(0 <= i && i < N);
// ...

Например, при написании задачи про кучу можно было бы после каждого извлечения максимума или добавления значения в кучу проверять её структуру с помощью следующей функции:

int check_heap(int heap_size, int heap[])
{
    int i;
    for (i = 0; i < heap_size; ++i) {
        assert(heap[i] >= heap[2 * i + 1] && heap[i] >= heap[2 * i + 2]);
    }
}

Конечно, уже после отладки эту проверку следует удалить, так как она понижает время работы кучи с логарифмического до линейного.

При использовании assert надо учитывать один важный момент: несмотря на то, что он похож на функцию, на самом деле это макрос. Макросы – это текстовые подстановки, которые могут иметь аргументы. На подстановку макросов могут оказывать влияние подстановки, определённые через #define, и assert запрограммирован так, что он реагирует на константу NDEBUG (no debugging, без отладки). Если она определена, assert будет заменён пустой строкой, и выражение внутри assert не будет выполнено вообще. Поэтому не стоит помещать в assert выражения, изменяющие переменные (например, a[i++] > 0) или делающие ввод-вывод, поскольку потом вы, возможно, заходите скомпилировать свою программу без отладочных проверок, и тем самым удалите изменения переменных или ввод-вывод, который нужны вашей программе.