diff --git a/cpp/cpp_chapter_0142/text.md b/cpp/cpp_chapter_0142/text.md index 2bb347af..051425ab 100644 --- a/cpp/cpp_chapter_0142/text.md +++ b/cpp/cpp_chapter_0142/text.md @@ -659,7 +659,7 @@ Accelerometer measurement #1 Кроме того, отсутствует удобный способ избежать случайного выхода за границы сишного массива. Зато в контейнере `std::array` такой способ есть. Метод `at()`, в отличие от оператора `[]`, проверяет индекс и в случае выхода за границы бросает исключение. -Сишные массивы нельзя присваивать друг другу и сравнивать через операторы `=` и `==`. Все присваивания и сравнения выполняются поэлементно, в цикле. +Сишные массивы нельзя присваивать друг другу и сравнивать через операторы `=` и `==`. Все присваивания и сравнения выполняются поэлементно, в цикле. {#block-compare} ### Советы по работе с массивами {#block-advice} diff --git a/cpp/cpp_chapter_0162/text.md b/cpp/cpp_chapter_0162/text.md index f3b58ed0..f3e5ee0e 100644 --- a/cpp/cpp_chapter_0162/text.md +++ b/cpp/cpp_chapter_0162/text.md @@ -1,3 +1,406 @@ -## Глава 16.2. Адресная арифметика +# Глава 16.2. Адресная арифметика -Глава находится в разработке. \ No newline at end of file +Итак, указатель — это переменная, хранящая адрес. И размер указателя не зависит от типа, на который он ссылается. Зачем же в момент объявления сообщать компилятору об этом типе? Какая разница, чей адрес содержит указатель — `bool`, `long int` или `std::queue`? + +## Зачем указателю знать свой тип данных + +Память упрощенно можно представить как массив ячеек по 1 байту. Кстати, именно поэтому нельзя завести переменную размером в 3 или 11 бит при условии, что байт равен 8 бит. + +Допустим, у нас есть три локальных переменных размером в 1, 4 и 8 байт: + +```cpp +int ret_code = -5; +bool retry = false; +bool * p = &retry; +``` + +![Переменные в адресном пространстве](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/cpp-chapter-16/illustrations/cpp/pointers_and_addresses.jpg) {.illustration} + + +И если бы мы захотели по указателю `p` обновить значение `retry`, но компилятор имел бы _неправильное_ представление о размере этой переменной, то вместо перезаписи 1 байта мы бы перезаписали 4, 8 или больше. Мы бы повредили значения соседних переменных. А возможно, даже ячеек, в которых расположен сам указатель `p`! Такая ситуация называется **повреждением памяти** (memory corruption). + +Еще одна причина, по которой компилятору важно знать о типе указателя — это **адресная арифметика.** + +## Что такое адресная арифметика + +С помощью указателей можно перемещаться по памяти. В частности, перебирать элементы массива. Для этого к указателям применяются арифметические операции `+` и `-`, работающие _с учетом типа._ + +Допустим, у нас есть указатель `p` типа `T *` и целое число `n`. Тогда к `p` применимы арифметические операции: +- `p + n`. Сложение указателя с целым числом увеличивает адрес на это число, домноженное на размер типа `T`. +- `p - n`. Вычитание из указателя целого числа уменьшает адрес на значение `n`, домноженное на размер типа. +- `p - p_other`. Вычитание из указателя другого указателя дает количество элементов типа `T` между ними. Операция имеет смысл для указателей одного типа, ссылающихся на непрерывный блок памяти. + +Также к указателям применимо сравнение операторами `>`, `>=`, `<`, `<=`, `==` и `!=`. При этом происходит сравнение адресов, на которые они указывают. Сравнение на больше-меньше имеет смысл только в случае, если указатели ссылаются на одну и ту же область памяти. + +Разберем подробнее каждую из арифметических операций. Удобнее всего это делать на примере указателей на элементы сишных массивов. Заодно узнаем, что такое сишные строки. + +## Сишные массивы + +Элементы сишных массивов расположены друг за другом [в непрерывной области памяти.](/courses/cpp/chapters/cpp_chapter_0142/#block-c-array-under-the-hood) Убедимся в этом: переберем в цикле все элементы массива и выведем их адреса. + +```cpp +std::println("Size of int: {} bytes", sizeof(int)); + +const std::size_t n = 5; +int pow_series[n] = {16, 32, 64, 128, 256}; + +std::println("\nArray of ints:"); + +for (std::size_t i = 0; i < n; ++i) +{ + std::println("Address: {}. pow_series[{}]={}", + static_cast(&pow_series[i]), i, pow_series[i]); +} +``` +``` +Size of int: 4 bytes + +Array of ints: +Address: 0x7ffd9239f430. pow_series[0]=16 +Address: 0x7ffd9239f434. pow_series[1]=32 +Address: 0x7ffd9239f438. pow_series[2]=64 +Address: 0x7ffd9239f43c. pow_series[3]=128 +Address: 0x7ffd9239f440. pow_series[4]=256 +``` + +Как видите, разность между адресами соседних элементов совпадает с размером типа элемента массива. + +Чтобы присвоить указателю адрес элемента массива, к этому элементу применяется оператор взятия адреса `&`: + +```cpp +int * p = &arr[0]; +``` + +Для нулевого элемента вместо этой записи можно использовать более лаконичную: + +```cpp +int * p = arr; +``` + +Здесь срабатывает неявное приведение типов: тип сишного массива `int[5]` приводится к типу указателя `int *` на нулевой элемент. + +Синтаксис языка позволяет применять к указателю оператор `[]`, чтобы через него обращаться к элементам массива: + +```cpp +std::uint64_t seed[] = { + 9621534751069176051UL, + 2054564862222048242UL + }; + +std::uint64_t * p = seed; +std::println("{} {}", *p, p[1]); +``` +``` +9621534751069176051 2054564862222048242 +``` + +## Сишные строки + +Вы уже знакомы с классом строки `std::string` из стандартной библиотеки. У него [около двадцати](https://en.cppreference.com/w/cpp/string/basic_string/basic_string.html) перегрузок конструктора. В частности, есть перегрузка для инициализации литералом в двойных кавычках: + +```cpp +std::string protocol = "UART"; +``` + +Но [какой тип](https://en.cppreference.com/w/cpp/language/string_literal.html) у самого литерала? Перед вами так называемая сишная строка, а по сути — сишный массив символов. У него тип `const char[n]`, где `n` — длина литерала + 1. Например, у литерала `"UART"` тип `const char[5]`. + +Дополнительный элемент массива отводится под [завершающий ноль](https://en.wikipedia.org/wiki/Null_character) (terminating null character) — символ с кодом `U+0000`. Это маркер конца строки. Его не нужно добавлять к литералу вручную: компилятор сам запишет его в последний элемент. Завершающий ноль обозначается как `\0`: + +```cpp +char null_char = '\0'; +``` + +При передаче в функцию сишная строка приводится к указателю (array-to-pointer decay). Но в отличие от массива, она не требует передачи дополнительного параметра — длины. Не обязательно знать длину строки, чтобы избежать выхода за ее границы: достаточно помнить про завершающий символ `\0`. + +В стандартной библиотеке C++ есть [функции](https://en.cppreference.com/w/cpp/header/cstring.html) для работы с сишными строками. Например, [std::strcmp()](https://en.cppreference.com/w/cpp/string/byte/strcmp.html) для сравнения строк. Ведь строки, как и массивы, [нельзя сравнивать напрямую.](/courses/cpp/chapters/cpp_chapter_0142/#block-compare) + +```cpp +// Возвращает 0, если строки равны. +// -1, если первая строка лексикографически +// меньше второй и 1, если она больше. +// int strcmp( const char* lhs, const char* rhs ); + +int main() +{ + char proto1[] = "UART"; + char proto2[] = "I2C"; + std::println("{}", std::strcmp(proto1, proto2)); +} +``` +``` +1 +``` + +## Прибавление к указателю целого числа + +Перебирать сишный массив можно не только через индексы, но и с помощью указателя. Делается это обычным инкрементом: `++p`, `p++`, `p += n` или `p = p + n`. + +Когда к указателю прибавляется целое число, то хранящийся в нем адрес увеличивается на соответствующее значение, умноженное на размер типа данных. Если прибавить к указателю число, не превышающее длину массива, то он будет ссылаться на один из последующих элементов: + +```cpp +const std::size_t n = 4; +double thresholds[]{0.009, 0.01, 0.5, 1.5}; + +double * p = &thresholds[0]; +std::println("{}", *p); // 0.009 + +++p; +std::println("{}", *p); // 0.01 + +p += 2; +std::println("{}", *p); // 1.5 + +++p; // Выход за пределы массива +std::println("{}", *p); // UB +``` +``` +0.009 +0.01 +1.5 +??? UB +``` + +![Перемещение по массиву с помощью указателя](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/cpp-chapter-16/illustrations/cpp/pointer_to_array_element.jpg) {.illustration} + +Важно следить, чтобы указатель не вышел за границы массива. Обращение по такому указателю приведет к UB. + +Стандарт [определяет,](https://timsong-cpp.github.io/cppwp/std23/expr.sub#2) что запись `arr[i]` эквивалентна выражению `*(arr + i)`. Разберем по шагам, что в нем происходит: +1. Внутри круглых скобок массив неявно приводится к указателю. +2. К хранящемуся в указателе адресу прибавляется число `i`, умноженное на размер типа. +3. Затем к нему применяется разыменование `*`. Мы получаем значение массива по индексу `i`. + +```cpp +std::uint8_t cmyk_color[] = {73, 45, 0, 4}; + +std::println("cmyk_color[3] == {}. *(cmyk_color + 3) == {}", + cmyk_color[3], + *(cmyk_color + 3)); +``` +``` +cmyk_color[3] == 4. *(cmyk_color + 3) == 4 +``` + +А теперь следите за руками. Оператор `+` [коммутативный:](https://ru.wikipedia.org/wiki/%D0%9A%D0%BE%D0%BC%D0%BC%D1%83%D1%82%D0%B0%D1%82%D0%B8%D0%B2%D0%BD%D0%BE%D1%81%D1%82%D1%8C) `a + b` и `b + a` — это одно и то же. То есть `*(arr + i)` можно записать как `*(i + arr)`. Это в свою очередь соответствует записи `i[arr]`. Скомпилируется ли код вида `i[arr]`? Да! Следует ли использовать такую форму записи в промышленном коде? Категорически нет! Она эзотерическая и сбивающая с толку. Зачем тогда о ней знать? Исключительно потому что о ней любят спрашивать на собеседованиях. + +Что выведет этот код? Напишите `err` в случае ошибки или `ub` в случае неопределенного поведения. {.task_text} + +```cpp +std::unit8_t rgb_color[] = {66, 135, 245}; + +std::println("{}", 1[rgb_color]); +``` + +```consoleoutput {.task_source #cpp_chapter_0162_task_0010} +``` +. {.task_hint} +```cpp {.task_answer} +135 +``` + +Итак, выражение `arr[i]` эквивалентно выражению `*(arr + i)`. Которое в свою очередь можно заменить на `*(ptr + i)` и даже `ptr[i]`, если `ptr` ссылается на нулевой элемент массива. + +Эквивалентны ли выражения `&arr[i]` и `ptr + i`? `y/n` {.task_text} + +```consoleoutput {.task_source #cpp_chapter_0162_task_0020} +``` +. {.task_hint} +```cpp {.task_answer} +y +``` + +Обойдем массив с помощью указателей: + +```cpp +const std::size_t n = 3; +std::size_t sizes[n] = {16384, 32768, 65536}; + +std::size_t * ptr = sizes; + +for (std::size_t i = 0; i < n; ++i) +{ + std::println("i={}. Addr={}. Val={}", + i, static_cast(ptr + i), *(ptr + i)); +} +``` +``` +i=0. Addr=0x7fffc75c9740. Val=16384 +i=1. Addr=0x7fffc75c9748. Val=32768 +i=2. Addr=0x7fffc75c9750. Val=65536 +``` + +В стандартной библиотеке есть функция [std::strchr()](https://en.cppreference.com/w/cpp/string/byte/strchr.html), которая принимает сишную строку и символ. Она возвращает указатель на первое вхождение символа в строку. Завершающий ноль _участвует_ в поиске. Если символ не найден, функция возвращает `nullptr`. {.task_text} + +Напишите свою реализацию функции. Будем считать, что в нее не может быть передан `nullptr`. {.task_text} + +```cpp {.task_source #cpp_chapter_0162_task_0030} +const char * strchr(const char * str, int ch) +{ + +} +``` +. {.task_hint} +```cpp {.task_answer} + +``` + +## Вычитание из указателя целого числа + +Вычитание целых чисел из указателей работает по той же схеме, что и сложение: из адреса отнимается значение, умноженное на размер типа. + +```cpp +// Can be used for creating seeds for random generators +std::uint64_t random_numbers[] = { + 0x0c8ff307dabc0c4cULL, + 0xf4bce78bf3821c1bULL, + 0x4eb628a1e189c21aULL, + 0x85ae000d253e0dbcULL + }; + +std::uint64_t * p = &random_numbers[3]; +std::println("Address: {}. Value: {:0x}", + static_cast(p), *p); + +p--; +std::println("Address: {}. Value: {:0x}", + static_cast(p), *p); + +p -= 2; +std::println("Address: {}. Value: {:0x}", + static_cast(p), *p); + +--p; // Выход за пределы массива +std::println("Address: {}. Value: {:0x}", + static_cast(p), *p); // UB +``` +``` +Address: 0x7fffa1731128. Value: 85ae000d253e0dbc +Address: 0x7fffa1731120. Value: 4eb628a1e189c21a +Address: 0x7fffa1731110. Value: c8ff307dabc0c4c +??? UB +``` + +Напишите функцию `reverse()`, которая принимает указатель на сишную строку. Она должна перевернуть строку, то есть расположить ее символы в обратном порядке. Считаем, что в функцию не может быть передан `nullptr`. {.task_text} + +```cpp {.task_source #cpp_chapter_0162_task_0040} +void reverse(char * str) +{ + +} +``` +. {.task_hint} +```cpp {.task_answer} +void reverse(char * str) +{ + char * start = str; + char * end = start; + + while (*end != '\0') + ++end; + + --end; + + while (start < end) + { + std::swap(start, end); + ++start; + --end; + } +} +``` + +## Вычитание указателей + +Указатели можно вычитать один из другого. Для этого они должны иметь одинаковый тип и ссылаться на области непрерывного участка памяти. Например, указывать на элементы одного и того же массива. + +```cpp +int http_non_retriable_errors[] = { + 400, // Bad Request + 401, // Unauthorized + 404, // Not found + 403, // Forbidden + 501, // Not implemented + 405, // Method not allowed +}; + +// Указатель на нулевой элемент массива: +int * p1 = http_non_retriable_errors; + +int * p2 = &http_non_retriable_errors[5]; + +std::println("{}", p2 - p1); +``` +``` +5 +``` + +Разность между указателями равна количеству объектов заданного типа между ними, а вовсе не количеству байт! При вычитании указателей компилятор делит получившееся значение на размер типа данных. Поэтому через разность указателей на элементы массива можно определять расстояние между ними. + +Что выведет этот код, если размер `int` равен 4 байта? Напишите `err` в случае ошибки компиляции или `ub` в случае неопределенного поведения. {.task_text} + +```cpp +import std; + +std::size_t size_ptr(int * buf) +{ + return sizeof(buf); +} + +std::size_t size_arr(int buf[]) +{ + return sizeof(buf); +} + +int main() +{ + int raw_data[5] = {}; + std::size_t a = &raw_data[4] - raw_data; + std::size_t b = sizeof(raw_data); + + std::println("{} {} {} {}", + a, + b, + b == size_ptr(raw_data), + b == size_arr(raw_data)); +} +``` + +```consoleoutput {.task_source #cpp_chapter_0162_task_0050} +``` +. {.task_hint} +```cpp {.task_answer} +4 20 false false +``` + +В стандартной библиотеке есть функция `std::strlen()`, которая принимает сишную строку и возвращает ее длину без учета завершающего нулевого символа. {.task_text} + +Напишите свою реализацию такой функции. Будем считать, что в нее не может попасть `nullptr`. {.task_text} + +```cpp {.task_source #cpp_chapter_0162_task_0060} +std::size_t strlen(const char * str) +{ + +} +``` +Заведите указатель на начало строки: `const char * end = str`. В цикле, пока значение по указателю не станет равным `'\0'`, увеличивайте указатель. Затем верните разность между указателем на `'\0'` и указателем на начало строки. В соответствии с правилами адресной арифметики получившееся значение равно количеству элементов между двумя указателями. {.task_hint} +```cpp {.task_answer} +std::size_t strlen(const char * str) +{ + const char * end = str; + while (*end != '\0') + ++end; + return end - str; +} +``` + +Вы можете сравнить свое решение задачи с вариантом, предлагаемом [в разделе cppreference,](https://en.cppreference.com/w/cpp/string/byte/strlen.html) посвященном `std::strlen()`. + + +---------- + +## Резюме +- Сишная строка — это массив `char`, завершенный символом `\0`. +- Адресная арифметика заключается в применении к указателям операций сложения и вычитания, которые работают с учетом типа указателя. +- Прибавление к указателю целого числа увеличивает значение адреса на это число, домноженное на размер типа. +- Вычитание из указателя целого числа уменьшает значение адреса на число, домноженное на размер типа. +- Вычитание из указателя другого указателя имеет смысл, если оба указателя ссылаются на общий участок памяти и имеют одинаковый тип. +- Вычитание одного указателя из другого возвращает количество элементов типа `T` между ними. diff --git a/cpp/cpp_chapter_0163/text.md b/cpp/cpp_chapter_0163/text.md index 7fce1c84..3ad70f22 100644 --- a/cpp/cpp_chapter_0163/text.md +++ b/cpp/cpp_chapter_0163/text.md @@ -1,3 +1,226 @@ # Глава 16.3. Динамическое выделение памяти -Глава находится в разработке. \ No newline at end of file +Как вы [помните,](/courses/cpp/chapters/cpp_chapter_0090/#block-memory) виртуальное адресное пространство процесса разбито на секции. + + +![Упрощенное представление памяти процесса](https://raw.githubusercontent.com/senjun-team/senjun-courses/cpp-chapter-9/illustrations/cpp/process_memory.jpg) {.illustration} + + +Переменные могут располагаться в одной из трех областей: +- **Статическая память** содержит переменные со статическим временем жизни. Это глобальные переменные и объекты, помеченные ключевым словом `static`. +- **Автоматическая память** (стек) хранит локальные переменные и аргументы функций. Их временем жизни управляет компилятор. С точки зрения разработчика оно регулируется автоматически. +- **Динамическая память** (куча, heap) содержит переменные, память под которые выделяется вручную из кода программы. Происходит это во время исполнения, то есть динамически. + +Статическая и автоматическая области памяти имеют крайне ограниченный размер. Под статическую память процесса как правило выделяется 2 Кб, а под автоматическую — 2 Мб. У вас не получится завести там _действительно_ большие объекты. Для них предназначена динамическая память. + +Почему же тогда работает заполнение стандартных контейнеров десятками миллионов элементов? Когда вы внутри функции заводите вектор, переменная типа `std::vector` размещается в автоматической памяти (на стеке). Но у вектора есть приватное поле — указатель на кусок динамической памяти. Там и живут элементы вектора. Получается, что объект вектора со всеми полями находится на стеке. Но одно из полей ссылается на динамическую память. И управление этой памятью реализовано в методах вектора. + +Чтобы управлять выделением и освобождением динамической памяти, в языке предусмотрены парные операторы: +- `new` и `delete` для объектов. +- `new[]` и `delete[]` для массивов. + +## Операторы new и delete + +Временем жизни переменных в динамической памяти управляет разработчик. Оно _не ограничено_ областью видимости. Переменная создается с помощью оператора [new](https://en.cppreference.com/w/cpp/language/new.html) и уничтожает оператором [delete](https://en.cppreference.com/w/cpp/language/delete.html). Работа с переменной организуется через указатель. + +```cpp +int * x = new int{6000}; + +*x += 2; +std::println("{}", *x); + +delete x; +``` +``` +6002 +``` + +При выполнении выражения `int * x = new int{6000}` происходит следующее: +1. На куче выделяется память под тип `int`. Например, 4 байта. +2. Она инициализируется целочисленным значением `6000`. +3. Оператор `new` возвращает указатель на эту память. +4. Указатель сохраняется в переменную `x`. + +В момент вызова `delete x` выделенная память помечается свободной, а указатель `x` становится висячим (dangling pointer): он перестает указывать на корректную область памяти. В любой момент по этому адресу может быть создана другая переменная. + +Если у типа есть конструктор, то он срабатывает при вызове `new` сразу после выделения памяти. При вызове `delete` сначала вызывается деструктор, а потом освобождается память. + +Создадим класс `Demo`, чтобы посмотреть в консоли стадии жизни объекта: + +```cpp +class Demo +{ +public: + Demo() + { + std::println("Default constructor"); + } + + Demo(int a, int b) + { + std::println("Parameterized constructor. Args: {}, {}", a, b); + } + + ~Demo() + { + std::println("Destructor"); + } + + void run() + { + std::println("Calling method"); + } +}; +``` + +Заведем объект `Demo` в динамической памяти, вызовем его метод, а затем уничтожим: + +```cpp +int main() +{ + Demo * d = new Demo{8, 9}; + d->run(); + delete d; + std::println("Exiting main"); +} +``` +``` +Parameterized constructor. Args: 8, 9 +Calling method +Destructor +Exiting main +``` + +Обратите внимание, что разрушение объекта произошло до вывода строки `"Exiting main"`. + +Операторы `new` и `delete` парные. На один вызов `new` должен приходиться строго один вызов `delete`: +- Если вы _не вызовите_ `delete` для созданного через `new` объекта, то получите **утечку памяти.** Выделенная память будет возвращена ОС только при завершении программы. +- Если вы вызовите `delete` _дважды,_ то получите **двойное освобождение памяти.** Это повреждение памяти, которое может привести к произвольным последствиям. Иными словами, это типичное UB. + +## Операторы new[] и delete[] + +У сишного массива константная длина. Если он создан глобально или помечен как `static`, то располагается в статической области памяти. В остальных случаях такой массив живет в автоматической памяти. + +Есть способ превратить сишный массив в динамический и создать его на куче. Для этого используются версии операторов с квадратными скобками [new[]](https://en.cppreference.com/w/cpp/memory/new/operator_new.html) и [delete[]](https://en.cppreference.com/w/cpp/memory/new/operator_delete.html). + +Так выглядит создание неинициализированного массива и его уничтожение: + +```cpp +int n = 5; +int * arr = new int[n]; + +// ... Заполняем массив, работаем с ним + +delete[] arr; +``` + +В квадратные скобки конструкции `new T[n]` может быть передано любое целое положительное число. Оно не обязано быть константой. Квадратные скобки `delete[]` всегда пусты. + +Чтобы инициализировать массив, в фигурных скобках приводятся его значения: + +```cpp +double * arr = new double[3]{1.0, 2.2, 7.8}; + +delete[] arr; +``` + +## Работа с динамической памятью на примере вектора + +Теперь у нас есть все необходимое, чтобы реализовать примитивную, но работоспособную реализацию вектора целых чисел. Сначала опишем интерфейс класса. А потом добавим реализацию методов. + +```cpp +class Vector +{ +public: + // Создает пустой вектор + Vector() = default; + + // Создает вектор из n элементов со значением val + Vector(std::size_t n, int val); + + // Освобождает выделенную под вектор память + ~Vector(); + + // Возвращает количество элементов + std::size_t size(); + + // Возвращает вместимость - под сколько элементов + // выделена память + std::size_t capacity(); + + // Возвращает элемент по индексу + int & at(std::size_t i); + + // Добавляет элемент в конец + void push_back(int val); + + // Удаляет последний элемент + void pop_back(); + + // Изменяет вместимость вектора + void reserve(std::size_t capacity); + + // Изменяет реальный размер вектора. Может + // удалить элементы с конца + void resize(std::size_t size); + + // Очищает вектор + void clear(); + +private: + // Реальное количество элементов + std::size_t m_size = 0; + + // Емкость: сколько элементов выделена память + std::size_t m_capacity = 0; + + // Указатель на сишный массив, в котором будем + // держать элементы + int * m_elements = nullptr; +}; +``` + +Мы завели перегрузку конструктора, позволяющую создавать вектор из `n` элементов со значением `val`: + +```cpp +Vector v{5, 9}; +``` + +Добавим метод `at()`. Он принимает индекс элемента и возвращает на него ссылку. + +```cpp +class Vector +{ +public: + Vector(); + Vector(std::size_t n, int val); + ~Vector(); + + // Объявление + int & at(std::size_t i); + +private: + std::size_t m_size = 0; + std::size_t m_capacity = 0; + int * m_elements = nullptr; +}; + +// ... Определения конструкторов и деструкторов + +int & Vector::at(std::size_t i) +{ + if (i >= m_size) + { + throw std::out_of_range( + std::format("Index {} is out of bounds. Vector size: {}", + i, m_sizee)); + } + + return m_elements[i]; +} +``` + + +## Указатели на указатели + +## Возврат значения по указателю \ No newline at end of file