Память I

Кувшинов Д.Р.

2015


Общее оглавление


Основные определения

Глобальная переменная — переменная, определённая вне функций и доступная из разных функций.

Локальная переменная — переменная, определённая внутри некоторой функции и доступная только из неё.

Класс хранения переменной — способ выделения памяти под переменную, также определяет время её жизни.

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

Утечка памяти memory leak — уменьшение доступной программе памяти, вызванное ошибочными действиями самой программы. Как правило, происходит при выделении блоков динамической памяти (см. ниже), адреса которых затем по какой-то причине теряются, из-за чего программа не может ни использовать их, ни вернуть системе эти блоки памяти.

Классы хранения

  1. Автоматическая переменная (только локальные переменные могут быть автоматическими). Память под автоматическую переменную выделяется автоматически всякий раз, когда исполнение кода достигает определения этой переменной. Память освобождается, когда исполнение кода продолжается за пределами самого внутреннего блока, в котором была определена эта переменная. Автоматические переменные встроенных типов по умолчанию не инициализируются, для инициализации необходимо присвоить начальное значение явно.
  2. Статическая переменная (статическими могут быть как глобальные, так и локальные переменные) — память под статические переменные распределяется компилятором и выделяется при запуске программы, а освобождается при завершении программы. Все глобальные переменные — статические. Для определения статической локальной переменной используется ключевое слово static. Для объявления глобальной переменной, внешней к данной единице трансляции, либо внутри функции используется ключевое слово extern (чтобы это объявление не было понято компилятором как определение локальной переменной). На старте программы статические переменные по умолчанию инициализируются нулями (естественно, можно указать своё инициализирующее значение).
  3. Динамическая переменная не может быть объявлена или определена непосредственно с назначением имени, а только создана во время работы программы с назначением адреса другой переменной. Память под динамические переменные в C и C++ выделяется и освобождается программистом явно с помощью операторов new-delete или стандартных функций.

Во время исполнения программы разным классам хранения могут соответствовать физически различные области памяти.

// Определение статической константы.
const char SPACE = ' ';

// (Неконстантный) глобальный указатель на статическую константу.
const char* ATK_MI = "Missiles incoming!";
// Константный глобальный указатель на статическую константу, доступный из других единиц трансляции.
extern const char* const EMPTY = "";

// Определение глобальной переменной, размещённой в этой единице трансляции.
int x;

// Объявление глобальной переменной, размещённой в какой-то другой единице трансляции.
extern float y;

// Определение глобальной переменной, невидимой компоновщику,
// т.е. не доступной из других единиц трансляции.
static double z = 1.0; // Инициализируем ненулевым значением.

// Данная функция сохраняет в своей статической локальной переменной значение аргумента
// первого вызова и возвращает его (и затем только его).
int return_its_first_argument(int arg)
{
  // Статическая переменная, доступная только внутри этой функции.
  // Инициализируется при первом вызове функции.
  static int first_arg = arg;
  return first_arg;
}

int x_plus(int y)
{
  // y -- локальная автоматическая переменная (параметр),
  // "затеняет" глобальную переменную y (к которой, впрочем, можно обратиться через ::y),
  // а вот x здесь -- та самая глобальная переменная, определённая выше
  return x + y;
}

double z_plus_other_z()
{
  // Объявление некоторой глобальной переменной z, определённой в другой единице трансляции.
  // "Затеняет" статическую z, определённую выше (в этой единице трансляции).
  extern double z;
  // Однако, мы можем обратиться к той z, что определена в этой единице трансляции через ::z.
  return z + ::z;
}

Модификаторы доступа

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

  1. Модификатор const задаёт константу, значение которой можно использовать в выражениях, но нельзя изменять (т.е. память “только для чтения”). Комбинация const с указателем обычно используется для того, чтобы показать, что нельзя изменять значение, на которое указывает указатель. При этом обычно можно изменять сам указатель.
  2. Модификатор volatile задаёт переменную, значение которой может быть доступно извне программы (изначально предполагался низкоуровневый доступ устройств к памяти, в том числе, в обход центрального процессора). Компилятор гарантирует, что он не будет изменять относительный порядок записи и чтения volatile переменных. В современных условиях volatile требуется использовать только в особых достаточно редких случаях, поэтому далее он не рассматривается.

Стек вызовов

Принцип действия

Хотя стек вызовов как способ организации вызовов функций и управления автоматическими переменными не закреплён в стандартах C и C++, он используется повсеместно. Понимание принципов его работы является важным для программиста.

Слово “стек” stack можно перевести как “стопка”. Объекты добавляются на стек сверху и снимаются потом в обратном порядке.

Стек: положили 1, 2, 3, 4, 5, 6 сняли 6, 5, 4
Стек: положили 1, 2, 3, 4, 5, 6 сняли 6, 5, 4

Возвращаясь к воображаемому компьютеру, использовавшемуся для управления роботом во введении, попробуем организовать на нём вычисление факториала F(n) натурального числа n по определению:

F(0) = 1,

F(n) = n F(n–1).

Факториал — излюбленный модельный объект при демонстрации или исследовании свойств рекурсии ввиду простоты. Другой подобный объект — последовательность чисел Фибоначчи, также традиционно задаваемая рекурсивным определением.

Итак, пусть есть переменные n и fact (результат вычисления факториала). Нам понадобится вспомогательная переменная n_1 и команды:

Итак, программа может начинаться следующим образом.

Факториал, первая попытка
Адрес Команда
0 beq n, 0, 13
// общий случай, n > 0
4 sub n_1, n, 1
// здесь надо как-то вычислить fact = F(n_1)
8 mul fact, n
11 j 16
13 store fact, 1
16 // выход из программы

В зависимости от значения n нам понадобится повторно заходить в код, вычисляющий факториал. Поэтому мы оформим этот код в виде вызываемой функции. Надо как-то организовать возможность вызова и возврата из функции результата. Для простоты будем считать, что результат будет записываться в переменную fact, а аргумент функции будет находиться в переменной n.

Создадим стек вызовов. Для этого определим новую переменную sp (от англ. stack pointer), будем считать, что в ней хранится адрес свободной позиции в массиве (стеке), созданном как раз для сохранения адресов возврата из функций.

При осуществлении вызова надо положить адрес возврата на стек вызовов:

  1. Сохранить адрес возврата (куда перейти после выполнения функции) по адресу sp.
  2. Увеличить sp на единицу (у нас стек растёт “вверх” — в сторону увеличения адресов).
  3. Перейти на адрес начала функции.

При осуществлении возврата из функции:

  1. Уменьшить sp на единицу.
  2. Перейти на адрес, хранимый в ячейке по адресу sp.

Для реализации данного протокола вызова понадобится добавить ещё команды:

Итак, оформим программу как рекурсивную функцию. Вспомогательная переменная ra будет использоваться для загрузки адреса возврата из стека.

Факториал, вторая попытка
Адрес Команда
0 beq n, 0, 32
// общий случай, n > 0
4 store n_1, n
7 sub n, n, 1
11 store [sp], 20
14 add sp, sp, 1
18 j 0
// сюда происходит возврат
20 mul fact, n_1
// возврат из функции
23 sub sp, sp, 1
27 load ra, [sp]
30 jr ra
// случай n = 0
32 store fact, 1
35 j 23

Упражнение. Попробуйте промоделировать поведение второго варианта программы Факториал на бумаге хотя бы для n = 2.

Полученный результат совсем не тот, что должен был быть. Если вы промоделировали работу программы, то ошибка должна быть очевидна — мы не сохраняем промежуточные значения переменной n (параметра функции), а они свои для каждого отдельного вызова (в переменной n_1 сохраняется только предпоследнее значение n). Что же делать? Ответ простой — использовать стек. Перед вызовом будем сохранять туда значение n, а после возврата — восстанавливать его.

Факториал, третья попытка
Адрес Команда
0 beq n, 0, 47
// общий случай, n > 0
4 store n_1, n
7 sub n, n, 1
11 store [sp], n_1
14 add sp, sp, 1
18 store [sp], 28
22 add sp, sp, 1
26 j 0
// сюда происходит возврат
28 sub sp, sp, 1
32 load n_1, [sp]
35 mul fact, n_1
// возврат из функции
38 sub sp, sp, 1
42 load ra, [sp]
45 jr ra
// случай n = 0
47 store fact, 1
50 j 38

Упражнение. Промоделируйте работу третьего варианта программы Факториал для n = 3.

Кадр стека

Кадр стека вызовов stack frame — кусок стека вызовов, сформированный одним вызовом функции.

В кадре стека размещаются все временные значения, которые необходимо хранить, а также значения локальных переменных. Выделение и освобождение памяти в стеке, происходит простым изменением указателя на текущую вершину стека (sp). С точки зрения операций центрального процессора это очень дешёвая операция, кроме того, освобождение памяти происходит автоматически (например, при возврате из функции). Но когда требуется большие объёмы памяти, то стека может не хватить (особенно при рекурсивных вызовах), в таких случаях приходится пользоваться динамической памятью.

Кадр стека вызовов (параметры могут включаться в кадр стека вызвавшей функции)
Кадр стека вызовов (параметры могут включаться в кадр стека вызвавшей функции)

Во время отладки отладчик позволяет “проходить” по кадрам стека вызовов, сформированных на данный момент. Таким образом можно понять, в каком порядке вызывались функции, из каких мест в исходном коде, каковы значения локальных переменных в каждом вложенном вызове. Эта информация может быть весьма полезна при поиске ошибок в логике программы.

Размер стека задаётся статически. В случае если во время исполнения программа попробует выйти за его пределы (что возможно при слишком глубокой рекурсии), поведение не определено (ситуация UB). В операционных системах с защитой памяти (в частности, современных версиях Windows и в большинстве Unix-подобных систем) программа будет остановлена с ошибкой. Если это произойдёт во время отладки, то отладчик, скорее всего, сообщит причину: “переполнение стека (вызовов)” (call) stack overflow.

Соглашение вызова

Соглашение вызова calling convention — правила, по которым осуществляется вызов функции на конкретной программно-аппаратной платформе (задаётся связкой конкретной архитектуры команд центрального процессора и операционной системы, например, на x86-32 и Windows NT одни правила, а на ARMv7 и GNU/Linux другие правила). Соглашение вызова — часть определяемого системой двоичного интерфейса приложения application binary interface, ABI, низкоуровневых особенностей функционирования программ на данной системе. Знание ABI может потребоваться в том числе для “склеивания” программ, написанных на разных языках программирования или откомпилированных разными компиляторами.

Соглашение вызова определяет (некоторые из этих элементов могут быть не стандартизованы, что делает разные компиляторы или даже разные версии одного компилятора не полностью совместимыми даже на одной платформе — впрочем, эта проблема не касается компиляторов C, если не предполагается использовать отладчик):

Конкретные соглашения вызова здесь рассматриваться не будут, так как это довольно узкая низкоуровневая специфика. Но представление об их существовании иметь следует. В качестве некоторых примеров соглашений вызова на существующих актуальных платформах можно привести ссылки на Википедию (для серьёзного изучения требуется изучать официальные спецификации, выпущенные для каждой системы, т.к. сведения Википедии могут быть недостаточно точны и полны): x86, краткий общий обзор.

Динамическая память

Стандартная библиотека C

Стандартная библиотека C предлагает всего пять функций для управления динамической памятью (выделение и освобождение блоков произвольного размера).

При использовании функции realloc нередко допускают следующую ошибку: присваивают результат realloc исходному указателю на блок:

char *mem;
...
// плохой код!
mem = realloc(mem, 2*size);

Так делать не следует из-за того, что realloc в случае невозможности выделить новый блок может вернуть нулевой указатель. При этом старый блок остаётся нетронутым, но указатель на него (mem) мы потеряли. Имеем ситуацию утечки памяти.

// корректный код, C++
if (auto new_mem = (char*)realloc(mem, 2*size))
  mem = new_mem;
else // невозможно выделить память
  ...

C++

В отличие от C, в C++ встроены операторы выделения (new) и освобождения (delete) динамической памяти.

Пусть T — имя некоторого типа.

auto ps = new string; // инициализированный объект -- пустая строка
assert(ps->empty());
auto pn = new int; // неинициализированный объект int, значение может быть любым
auto pi = new int(10); // инициализированный константой 10 объект int
auto i = int(10); // то же, что int i = 10;
// впрочем, синтаксис C++ не позволяет написать auto n = int;

Нельзя смешивать разные методы выделения и освобождения блоков динамической памяти. Если память получена функциями malloc, calloc, realloc или aligned_alloc, то освобождать её надо только функцией free. Если память получена “обычным” new (включая вариант с nothrow) для одиночного объекта, то освобождать её надо только вызовом delete. Если память получена оператором создания динамического массива new[], то освобождать её необходимо вызовом delete[]. Функцию realloc нельзя использовать с блоками, выделенными new или new[].

Прочее

Указатели и адреса

В данном курсе термины указатель pointer и адрес address используются как синонимы. На основных современных платформах (x86-64, ARMv8, MIPS64, PowerPC и др.) с точки зрения C и C++ это действительно так. Однако сам термин “указатель” подразумевает нечто большее, чем просто “адрес”. Указатель — это набор данных, необходимых для обращения к некоторому значению. В ряде случаев представление указателя может содержать дополнительные данные помимо собственно адреса в памяти процессора. Это может быть расширение адреса (например, индекс сегмента в системах с сегментированием памяти), это может быть дополнительная информация о типе значения (тег) или что-то ещё, что зависит от конкретной системы. Поэтому не следует отождествлять понятия “указатель” и “адрес” во всех контекстах.

Нулевой указатель

В C и C++ любая переменная или объект в памяти должны иметь адрес (за исключением переменных с классом хранения register). И этот адрес не равен специальному нулевому указателю null pointer, который используется как признак отсутствия некоторого объекта или значения. При преобразовании к типу указателя нулевой указатель порождается константой ноль (в случае C++ из чисел только литерал 0 приводится к указателю неявно). Во многих стандартных заголовочных файлах C определён стандартный макрос NULL. Его определение может выглядеть следующим образом:

#ifdef __cplusplus // случай для C++
#define NULL 0
#else // случай для C
#define NULL ((void*)0)
#endif

В C++11 концепция нулевого указателя была несколько развита введением специального стандартного типа nullptr_t и литерала nullptr, представляющего единственное значение этого типа. Таким образом, стало возможным использовать защиту на уровне системы типов при манипулировании указателями (ведь 0 — просто число, которое может пройти во многих контекстах) или при имитации модели нулевого указателя (например, инициализация своих объектов значением nullptr).

Итак, в качестве нулевого указателя в программах на C рекомендуется использовать стандартный макрос NULL (определён в stdlib.h и других заголовках), а в программах на C++ — ключевое слово nullptr.

Разыменование нулевого указателя порождает неопределённое поведение.

В отличие от указателей ссылки не могут быть “свободными”. Ссылка обязана быть привязана к существующему в памяти значению, в языке C++ нет никакой “нулевой ссылки”. Нарушение этого правила ведёт к неопределённому поведению.

Порядок байт

Исторически в различных семействах вычислительных систем использовались разные порядки отдельных байт многобайтных значений при укладке их в памяти. Как правило, используется либо порядок “младшие байты по младшим адресам” little-endian, LE, либо порядок “старшие байты по младшим адресам” big-endian, BE. В редких случаях встречаются некие другие варианты (“смешанные порядки”). Существующие основные процессорные архитектуры включают средства для работы с двумя указанными порядками, при этом обычно только один из них считается “основным”, используемым по умолчанию.

Например, пусть есть значение 1234567890 = 49’96’02’D216 типа uint32_t (32 бита = 4 байта). Как оно будет уложено в памяти на машине с поддержкой адресации отдельных байт? Для примера разместим его по адресу 16.

Число 49’96’02’D216, уложенное в памяти
Байт 16 17 18 19
BE-порядок 49 96 02 D2
LE-порядок D2 02 96 49

LE-порядок несколько естественнее с вычислительной точки зрения, BE-порядок удобнее читать человеку при просмотре значений памяти или файлов в двоичной форме (например, в “шестнадцатеричном редакторе”). BE-порядок традиционно используется в сетевых протоколах и рядом процессорных архитектур (MIPS, PowerPC, ARM, хотя они предоставляют возможность переключаться между LE и BE). LE-порядок традиционно используется процессорами семейства x86.

Выравнивание

Выравнивание alignment — размещение значений в памяти по адресам, кратным некоторому целому числу, большему единицы.

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

Естественное выравнивание natural alignment — выравнивание значений встроенных типов (как правило, поддерживаемых процессором непосредственно) по адресам, кратным размеру этого типа. Например, 4-байтные целые размещаются по адресам, кратным четырём (0, 4, 8, 12, …), а 8-байтные значения типа double размещаются по адресам, кратным восьми (0, 8, 16, 24, …).

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

Размер объекта типа (возвращаемый оператором sizeof) должен делиться на выравнивание нацело.

Можно получить (C++11) выравнивание типа как константу времени компиляции с помощью оператора alignof, аргументом которого служит имя типа.

В стандартном заголовочном файле cstddef, начиная со стандарта C++11, объявлен тип max_align_t, естественное выравнивание которого является максимальным среди всех стандартных встроенных типов. Выравнивание блоков динамической памяти, выделяемых стандартными средствами C и C++, не меньше значения alignof(max_align_t). По поводу выделения блоков динамической памяти с заданным выравниванием см. здесь.

Спецификатор alignas (C++11) позволяет задать желаемое выравнивание переменной или пользовательского типа (не нарушающее, впрочем, естественное выравнивание, т.е. кратное ему, в противном случае спецификатор игнорируется).

alignas(64) char cache_line[64]; // Хотим, чтобы адрес cache_line был кратен 64.
alignas(double) char bytes[8];   // Хотим, чтобы bytes имело выравнивание alignof(double).

// Переменные типа Double_2 должны иметь адреса, кратные 16.
struct alignas(16) Double_2
{
  double part[2];
};

Использование спецификатора alignas(0) не изменяет выравнивание переменной.

Массивы

Все элементы массива имеют одинаковый тип и, соответственно, удовлетворяют всем свойствам значений этого типа. По умолчанию, выравнивание массива совпадает с выравниванием типа элемента массива. Размер массива равен произведению количества элементов на размер элемента. Массив располагается в памяти единым блоком, без разрывов.

Структуры

По умолчанию, каждое поле структуры может считаться независимой переменной и выравнивается аналогично. Чтобы соблюсти это требование, объекты структур получают выравнивание, равное наименьшему общему кратному выравниваний своих полей. Так как обычно поддерживается только выравнивание по степеням двойки, НОК заменяется простым максимумом.

Для обеспечения выравнивания полей вставляются пропуски padding — байты, которые не используются и не принадлежат ни одному полю. Значения пропусков не определены, поэтому объекты структур с пропусками нельзя сравнивать на равенство побайтно (например, с помощью функции memcmp).

В случае форсирования выравнивания по 1 (т.е. отсутствии ограничений на адреса), структура называется упакованной. Нет стандартных средств для упаковки структур, но компиляторы предоставляют нестандартные средства. Для упакованных структур снимаются требования по выравниванию полей, что нередко приводит к меньшему итоговому размеру объекта структуры из-за отсутствия пропусков. Впрочем, такая экономия памяти обычно негативно сказывается на производительности.

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

struct S1
{
  char a, b;
  int32_t c;
};

struct S2
{
  int32_t c;
  char a, b;
};

struct S3
{
  char a;
  uint32_t c;
  char b;
};

// Упакованная структура.
// Нестандартное средство: компилятор MSVC.
#pragma pack(1)
struct S4
{
  char a, b;
  int32_t c;
};
Заполнение памяти объектами структур (через дефис указаны номера байт поля)
Адрес S1 S2 S3 S4
0 a c-0 a a
1 b c-1 пропуск b
2 пропуск c-2 пропуск c-0
3 пропуск c-3 пропуск c-1
4 c-0 a c-0 c-2
5 c-1 b c-1 c-3
6 c-2 пропуск c-2
7 c-3 пропуск c-3
8 b
9 пропуск
10 пропуск
11 пропуск


Итоговые значения alignof и sizeof
Структура alignof sizeof
S1 4 8
S2 4 8
S3 4 12
S4 1 6

Объединения

Ключевое слово union вводит тип, называемый объединение. Объединения напоминают структуры и могут содержать именованные поля разных типов. Однако, в отличие от структур все поля объединений имеют один и тот же адрес — адрес объекта объединения, т.е. все поля объединений разделяют одну и ту же память. Причины для использования объединений могут быть следующие:

Выравнивание объединения задаётся так же, как выравнивание структуры с теми же полями. Размер объединения равен наименьшему кратному его выравнивания, достаточному для того, чтобы поместилось поле самого большого размера. Например, следующее объединение имеет выравнивание 4 и размер 8 (предполагая, что float имеет размер 4), последний байт не используется:

union
{
  float value;
  char bytes[3 + sizeof float];
} data;

Битовые поля

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

// 16-битный RGB цвет.
struct Color_565
{
  unsigned red   : 5; // 5 бит на интенсивность красного
  unsigned green : 6; // 6 бит на интенсивность зелёного
  unsigned blue  : 5; // 5 бит на интенсивность синего
};

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

При обращении к битовому полю, значение приводится к указанному целочисленному типу. Если поле шире в битах, чем использованный тип, то лишние биты отбрасываются (путём обращения к полю их значение нельзя ни получить ни изменить). Если поле у́же, чем использованный тип, то происходит расширение значения до ширины типа. Для беззнаковых типов оставшиеся старшие биты заполняются нулями. Способ расширения значений знаковых типов определяется реализацией. Если использован целочисленный тип (например, int) без явного указания signed реализация может истолковать тип поля как беззнаковый (в то время как обычные поля или переменные будут знакового типа, так как для них signed подразумевается по умолчанию). При записи в битовое поле значения более широкого в битах типа старшие “лишние” биты значения отбрасываются.

Способ размещения битовых полей в памяти определяется реализацией. Поэтому при написании кросс-платформенного кода для обеспечения конкретного расположения битовых полей в памяти вместо синтаксиса битовых полей рекомендуется использовать поразрядные операции (сдвиги, наложения масок) явно.

Битовое поле может быть безымянным. Такие поля можно использовать для выравнивания. Безымянное битовое поле может иметь условную ширину 0 бит. Такое поле указывает компилятору, что следующее поле начинается, по крайней мере, с нового байта (зависит от реализации).

Нельзя получить адрес битового поля или ссылку на него.

Функции с переменным числом параметров

Язык C (и, по наследству, C++) допускает определение функций, принимающих переменное количество параметров. Это довольно низкоуровневый механизм, и не существует стандартного способа определить, сколько параметров было помещено в стек вызовов вызывающей функцией или какого они типа, поэтому обычно эта информация в некоторой форме передаётся первым параметром. Примерами таких функций являются стандартные функции printf и scanf. Общий синтаксис заголовка функции с переменным числом параметров имеет следующий вид:

Тип-результата Имя-функции ( Обязательные-параметры , ...)

Обязательные параметры объявляются так же, как в обычных функциях. Формально C++ (но не C) позволяет объявлять функции с переменным числом параметров, которые не принимают обязательных параметров, но определить такую функцию в рамках стандарта языка не получится.

При вызове функции с переменным числом параметров необходимо указать значения всех обязательных параметров и далее можно указать произвольное (от нуля до некоторого максимума, зафиксированного компилятором) количество дополнительных параметров, которые могут иметь разный тип. При этом при вызове значения типа float автоматически приводятся к double, значения целочисленных типов меньшей ширины, чем int (например, bool, char, short), приводятся к int.

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

Пример реализации функции с переменным числом параметров типа double:

/// Длина вектора с количеством компонент comps.
/// Компоненты вектора передаются параметрами функции.
double vec_len(int comps, ...)
{
  std::va_list args;
  va_start(args, comps);

  double s = 0.0;
  while (comps-- > 0)
  {
    const double arg = va_arg(args, double);
    s += arg * arg;
  }

  va_end(args);
  return std::sqrt(s);
}

C++, HTML


Общее оглавление

Кувшинов Д.Р. © 2015