Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
Дмитрий Козлюк 6c14501bc6
lecture03: функции, указатели, ссылки, структуры
2 лет назад
..
README.md lecture03: функции, указатели, ссылки, структуры 2 лет назад

README.md

Лекция 3. Функции, указатели, ссылки, структуры

Функции (повторение)

Функция — это именованный блок кода с формальными параметрами и возвращаемым значением.

Пример:

double
multiply(double x, int y) {
    return x * y;
}

Здесь double — тип возвращаемого значения (результата), multiply — имя функции, (double x, int y) — список формальных параметров, а в фигурных скобках — тело функции.

Если вместо типа возвращаемого значения указать void, это значит, что функция значения не возвращает.

Тип возвращаемого значения, имя функции и список формальных параметров могут быть разнесены по строкам в соответствии со стилем и читаемостью. Например, мы в лабораторных работах предпочитаем тип возвращаемого значения писать на отдельной строке. С точки зрения C++ это безразлично.

Типы ее формальных параметров составляют сигнатуру функции. Отметим, что тип возвращаемого значения, имя функции и имена параметров в сигнатуру не входят. Например, сигнатура multiply: (double, int).

Оператор return определяет возвращаемое значение и немедленно завершает функцию. В void-функциях тоже можно использовать return без указания результата, чтобы немедленно выйти из функции.

Внимание. Выполнение не-void функции всегда должно заканчиваться return, хотя C++ не отслеживает это жестко (но может выдать предупреждение).

Внутри тела функции её формальные параметры являются локальными переменными. Они независимы от переменных в месте вызова функции, даже если у них одинаковые имена:

void func(int x) {
    x = 66;
}
...
int x = 42;
func(x);
// x == 42

При вызове функции значения, переданные ей в качестве аргументов, копируются в её переменные-параметры.

Указатели (повторение)

Примечание. В этой лекции не рассматривается динамическое выделение памяти, а только сами указатели как тип данных и их применение в связи с функциями.

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

При объявлении указателей перед именем переменной ставится звездочка. Например, так объявляется указатель на действительное число:

double* r1;

Часто звездочку прижимают к имени типа, а не переменной, как в примере. Есть известная «ловушка»:

double* x, y;

Здесь только x является указателем (имеет тип double*), а y является обычной переменной (имеет тип double). Надо либо ставить звездочку перед каждой переменной-указателем, либо объявлять каждую переменную отдельно (это почти всегда нагляднее).

В указатель записывается не значение переменной, а ее адрес. Адрес берется оператором взятия адреса в виде амперсанда (&):

double x = 3.14;
double* p = &x;

Вот как расположены при этом данные в памяти:

адреса:        0   1       8   9   10  11  12      42  43  44  45  46
            +---+-     -+---+---+---+---+-     -+---+---+---+---+-
ячейки:     |   | ..... |     3.14      | ..... |       8       | ...
            +---+-     -+---+---+---+---+-     -+---+-.'+---+---+-
                        ↑\_____________/         \__.'_________/
                        |       x                 .'    p
                        |                       .'
                       &x = 8 = ...............'

Чтобы, имея указатель, обратиться к тем данным, адрес которых он хранит, используется оператор разыменования в виде звездочки:

*p = 2.71;  // x = 2.71

Есть специальное значение указателя — нулевой: NULL, 0 или nullptr. Указатель, хранящий такой адрес, запрещено разыменовывать.

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

Висячие указатели (dangling pointers)

К сожалению, C++ не отслеживает, что значение указателя всегда корректно. Рассмотрим пример:

int* p = nullptr;
if (...) {
    int x;
    p = &x;
    ...
}
cout << *p;

В последней строке p указывает на переменную, которая объявлена внутри if и уже не существует после выхода из фигурных скобок. Поэтому, хотя указатель и хранит не nullptr, разыменовывать его нельзя. Такие указатели на данные, которых уже нет, называются висячими (dangling).

Другой пример:

int* func() {
    int x = 42;
    return &x;
}
...
auto p = func();
cout << *p;

Здесь функция возвращает адрес локальной переменной. Однако локальные время жизни локальных переменных ограничено функцией, поэтому пользоваться таким возвращаемым значением нельзя.

При работе с указателями надо всегда думать о том, чтобы время жизни указателя не превышало время жизни данных, адрес которых указатели хранят.

Ссылки

Ссылка (reference) — это новое имя для существующего объекта. Объект может быть переменной или её частью, такой как элемент массива.

Ссылки объявляются с использованием амперсанда:

int var = 42;
int& ref = var;

Не следует путать амперсанд при объявлении ссылок с амперсандом-оператором взятия адреса!

Обращение к ссылке эквивалентно обращению к тому, на что она ссылается:

cout << ref;  // 42
ref = 66;
cout << var;  // 66

В частности, так как ссылка не является самостоятельной переменной, её адрес — это адрес того, на что она ссылается, а явное разыменование не нужно (если это не ссылка на указатель, конечно):

if (&var == &ref) { ... }   // истинно

*ref = 66;                  // ОШИБКА: обращение к ref — обращение к var,
                            // а var не указатель, разыменовать её нельзя.

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

int& ref;  // ОШИБКА: новое имя для чего?

Не бывает «нулевой ссылки», подобной нулевому указателю.

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

double& first = some_vector[0];
// ...
fisrt = 0;

Передача входных параметров функций по ссылкам

Рассмотрим функцию, суммирующую элементы вектора:

double sum(vector<double> xs) {
    double total{};
    for (double x : xs) {
        total += x;
    }
    return total;
}

Вспомним, что при вызове функции значения аргументов копируются в переменные-формальные параметры, то есть в xs будет помещена копия вектора, который передан функции. Если этот вектор большой, будет потрачено много лишней памяти, кроме того, это копирование бесполезно — функция не меняет xs даже внутри.

Можно передавать xs по ссылке, чтобы не копировать вектор, а работать с той переменной, которую передали функции, напрямую:

double sum(vector<double>& xs) { ... }
...
vector<double> xs;
double s = sum(xs);

Однако есть две проблемы:

  • Нельзя вызвать sum({1, 2, 3}), потому что {1, 2, 3} — выражение, а не переменная. Это запрещено, потому что с помощью ссылки возможно поменять то, на что она ссылается, однако выражение поменять нельзя в принципе. (Можно изменить значение переменной, содержащей 5 = 3 + 2, но нельзя поменять саму 5, «пятерку как таковую».)

  • При чтении кода непонятно, не меняет ли sum() свой аргумент, и нет гарантий, что она этого не начнет делать в будущем.

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

double sum(const vector<double>& xs) { ... }

При передаче параметров нетривиального типа (не int, double и т. п.), в том числе при передаче std::vector<T> и std::string, рекомендуется по умолчанию использовать константную ссылку.

Выходные параметры функций через указатели и ссылки

Составим функцию для решения квадратного уравнения в действительных числах. Очевидно, что она принимает коэффициенты уравнения. Возвращает она три значения:

  • признак, что действительные решения есть;
  • корень x1;
  • корень x2.

Однако у функции возвращаемое значение только одно, допустим, признак. Как вернуть корни?

Можно сделать это через ссылки:

bool solve(double a, double b, double c, double& x1, double& x2) {
    auto d = b*b - 4*a*c;
    if (d < 0) {
        return false;
    }
    x1 = (-b + sqrt(d)) / 2*a;
    x2 = (-b - sqrt(d)) / 2*a;
    return true;
}

Вызов функции будет выглядеть так:

double x1, x2;
if (solve(3, 2, 1, x1, x2)) {
    cout << "x1 = " << x1 << "\n"
         << "x2 = " << x2 << "\n";
} else {
    cout << "Нет действительных корней.\n";
}

Можно было бы использовать указатели:

bool solve(double a, double b, double c, double* x1, double* x2) {
    auto d = b*b - 4*a*c;
    if (d < 0) {
        return false;
    }
    *x1 = (-b + sqrt(d)) / 2*a;
    *x2 = (-b - sqrt(d)) / 2*a;
    return true;
}

Вызов функции будет выглядеть так:

double x1, x2;
if (solve(3, 2, 1, &x1, &x2)) {
    cout << "x1 = " << x1 << "\n"
         << "x2 = " << x2 << "\n";
} else {
    cout << "Нет действительных корней.\n";
}

Какой вариант лучше и почему?

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

solve(3, 2, 1, nullptr, &x2);

Программа успешно компилировалась бы, но при запуске аварийно завершилась, поскольку в функции solve() был бы разыменован нулевой указатель x1.

Может показаться, что из-за этого вариант с указателями хуже: функция должна проверять, что ей не передали нулевой указатель, а со ссылками этого не потребовалось бы — ведь «нулевых ссылок» нет. Однако наличие особого значения у указателя — не только проблема, но и возможность связать с этим значением особую логику. Например, функция могла бы быть реализована так:

bool solve(double a, double b, double c, double* x1, double* x2) {
    auto d = b*b - 4*a*c;
    if (d < 0) {
        return false;
    }
    if (x1) {
        *x1 = (-b + sqrt(d)) / 2*a;
    }
    if (x2) {
        *x2 = (-b - sqrt(d)) / 2*a;
    }
    return true;
}

Теперь, если передать в функцию nullptr в качестве x1 или x2, она не будет вычислять соответствующий корень. Таким образом программа может сэкономить вычисления, если оба корня ей заведомо не нужны. Можно даже передать nullptr в качестве и x1, и x2, тогда функция просто проверит, есть ли действительные решения — возможно, конкретной программе, которая использует solve(), только это и нужно.

Вывод: использовать для передачи выходных параметров указатели или ссылки зависит от того, нужна ли дополнительная гибкость логики, которую дает наличие особого значения — нулевого указателя.

Заметим, что некоторые проекты предпочитают всегда использовать указатели, даже если предполагается, что они обязаны не быть nullptr никогда. Причина в том, что в случае ссылок по вызову solve(a, b, c, x1, x2) невозможно определить, какие из переменных после этой строки могут поменяться. Вызов же solve(a, b, c, &x1, &x2) ясно показывает, что solve() может поменять x1 и x2.

Структуры

Структура — это пользовательский тип данных, представляющий собой совокупность именованных полей различных типов.

Структуры удобны для того, чтобы сгруппировать несколько переменных, которые используются в программе совместно. Например, в задаче ЛР № 1 можно было бы объединить входные данные в структуру:

struct Input {
    std::vector<double> numbers;
    size_t bin_count;
};

Здесь Input — имя структуры, а numbers и bin_count — её поля.

Важно понять, что определение выше описывает тип данных (аналог std::vector или std::string), а не переменную, то есть код выше не описывает переменные numbers и bin_count, куда можно сохранить значения. Он описывает, что каждая переменная типа Input содержит поля numbers и bin_count, в которые уже можно сохранить конкретные данные.

Переменные типа структур объявляются так же, как переменные других типов; именем типа выступает имя структуры:

Input x;

Говорят, что переменная x — экземпляр структуры.

К полям структуры обращаются через точку:

cout << x.numbers.size(); // 0
cin >> x.bin_count;
if (x.bin_count == 0) { ... }

Можно объявить несколько переменных типа структуры:

Input y;
Input z;

y.bin_count = 3;
z.bin_count = 4;

Значения, хранимые в x, y и z будут независимы друг от друга.

Инициализация полей

Вернемся к объявлению x, чему было равно x.bin_count до её ввода? Так как в определении структуры для поля bin_count не было указано значения по умолчанию, то и конкретное начальное значение x.bin_count не определено.

Удобно, чтобы у всех полей были начальные значения. Например, так можно инициализировать нулем bin_count любой новой переменной типа Input:

struct Input {
    vector<double> numbers;
    size_t bin_count{}; // или size_t bin_count = 0;
};

Как известно, переменные типа vector<T> по умолчанию содержат пустой вектор, поэтому для поля numbers о начальном значении заботиться не нужно.

Оператор «стрелка» (->)

На переменные типа структуры могут быть указатели:

Input* p = new Input;

Чтобы использовать оператор . для доступа к полям структуры, на которую указывает p, нужно сначала разыменовать p. Оператор . имеет наивысший приоритет, поэтому нужны скобки: (*p).bin_count. Это громоздко, поэтому в C++ введено оператор «стрелки» ->, чтобы записать то же самое проще: p->bin_count.

Перегрузка функций

Можно объявить набор функций с одинаковыми именами, но разными сигнатурами:

// print(3.14) -> 3.14
void print(double x) {
    cout << x;
}

// print({1, 2, 3}) -> {1, 2, 3};
void print(const std::vector<double>& xs) {
    cout << "{";
    bool need_comma = false;
    for (auto x : xs) {
        if (need_comma) {
            cout << ", ";
        } else {
            need_comma = true;
        }
        print(x);  // вызов print(double), а не рекурсия
    }
    cout << "}";
}

Говорят, что функция print() перегружена (overloaded), а каждая из конкретных функций называется её перегрузкой. Когда компилятор встречает вызов print(), он анализирует типы параметров и вызывает соответствующую перегрузку.

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

Объявление и определение функции

Будет ли компилироваться программа такого вида?

void foo() {
    bar();
}

void bar() {
    foo();
}

Функция foo() вызывает функцию bar() раньше, чем она описана, что вызовет ошибку. Однако перенести описание bar() выше описания foo() нельзя: bar() содержит вызов foo(), который тогда окажется выше, чем описана foo().

Рассуждая логически, для того, чтобы скомпилировать foo(), компилятору не нужно тело функции bar() — достаточно знать, что такая функция есть, и какие у нее параметры (нет параметров). Сообщить компилятору о сигнатуре функции можно с помощью объявления функции (declaration). В отличие от уже знакомого определения функции (definition), объявление не содержит тела, а кончается точкой с запятой:

void bar();     // объявление bar()

void foo() {
    bar();      // вызов bar()
}

void bar() {    // определение bar()
    foo();
}

Объявления и определения важны при делении программы на файлы.