hse-oimp.github.io

Последовательные контейнеры

Приведение типов

Типизация - механизм, который защищает нас от того, чтобы использовать объект одного типа вместо объекта другого, или позволяет управлять таким использованием. В данном разделе мы научимся именно управлять такими использованием, то есть приводить один тип к другому. Данная операция может быть как и безопасной (например int -> int64_t), так и опасной(int64_t -> int). Рассмотрим инстументы для приведения типов в С++:

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

     long longInt = 10; // напоминание: 10 - литерал типа int
    

Когда неявного приведения типов недостаточно, нужно использовать операторы, первый из которых

  1. Оператор приведения C-стиля. Если мы хотим привести один тип к другому, нам достаточно написать в скобочках тип, к которому мы хотим привести. Пример:
     int a = 7;
     double* ptr = (double*) &a;
    

    Недостатки такого приведения: в C++ есть 4 типа приведения (static_cast, reinterpret_cast, const_cast и dynamic_cast, о них будет ниже). Когда мы используем оператор приведения С-стиля, непонятно какой тип cast - a мы хотим использовать кастование, и зачастую используем не то, что хотим.

  2. static_cast - наиболее часто используемый тип приведения. Он предполагает, что если вы выражение одного типа прикастили к другому типу, то вы можете пользоваться получившимся выражением через новый тип, не приводя его куда-то обратно. Пример использования:
     int a = 7;
     int* ptr2 = static_cast<int*>(&a);
    

    Пример неправильного использования static_cast:

     int a = 7;
     double* ptr3 = static_cast<double*>(&a); // ошибка, потому что мы не можем для одной и той же области памяти использовать и int, и double.
    
  3. reinterpret_cast - наиболее опасный тип использования. Принцип работы: reinterpret_cast говорит компилятору: забудь тип этой области памяти, теперь это другой тип. Пример использования:
     int a = 7;
     double* ptr4 = reinterpret_cast<double*>(&a); //теперь эта область памяти храниться как double
    
  4. const_cast - тип приведения типов, который убирает константность. Пример использования:
     const int b = 8;
     int* ptr5 = static_cast<int*>(&b); // ошибка
     int* ptr6 = const_cast<int*>(&b);
    
  5. dynamic_cast - будет рассказано в лекции про наследование.

Пары и структуры

std::pair

std::pair - контейнер, который хранит в себе ровно 2 элемента. Для понимания всех возможностей std::pair, рассмотрим пример его использования:

std::pair<int, std::string> mypair;                 // тип первого элемента - int, второго - std::string
mypair = std::make_pair(1, "abacaba");              // первый вариант присвоить значение std::pair
mypair = {1, "abacaba"};                            // второй вариант
std::cout << mypair.first << ' ' << mypair.second;  // вывод - 1 abacaba
mypair.first = 2;
std::cout << mypair.first;                          // вывод - 2

Недостатки: нечитаемый код, так как зачастую мы имеем в паре 2 одинаковых типа, которые очень легко перепутать

Чтобы исправить вышеуказанный недостаток, можно воспользоваться еще одним инструментом С++:

Структуры

Допустим мы хотим создать тип Student, который хранит в себе имя и фамилию студента.

struct Student {
    std::string name;
    std::string last_name;
    int grade = 11; // дефолтная инициализация примитивных типов
};

Ниже представлен пример работы со Student-ом:

Student student;
student.name = "Ivan";
student.last_name = "Ivanov";
std::cout << student.grade;   //вывод - 11

Если мы напишем следующий код:

Student student{};

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

Student student{"Ivan", "Ivanov", 10};

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

Student student{"Ivan", "Ivanov"};

Такой способ инициализации явно имеет недостатки: при изменении порядка объявления переменных возникнут ошибки. Поэтому, начиная с С++20, поля можно инициализировать следующим образом:

Student student{.name = "Ivan", .last_name = "Ivanov"};

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

Student student{.last_name = "Ivanov", .name = "Ivan"}; // ошибка

Последовательные контейнеры

Мотивация: допустим нам на вход подается несколько элементов, мы хотим их как-то хранить и получать их в том же порядке. Для решения вышепоставленной задачи сущестувет несколько контейнеров, рассмотрим некоторые из них:

C-массивы

Рассмотрим синтаксис инициализации данного контейнера:

int numbers[5] = {0};               // numbers эквивалентен [0, 0, 0, 0, 0]. Работает только для 0
int numbers[5] = {1, 2, 3, 4, 5};   // numbers эквивалентен [1, 2, 3, 4, 5]
int numbers[5] = {1};               // numbers эквивалентен [1, 0, 0, 0, 0]
int[5] numbers = {0};               // ОШИБКА

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

void PrintNumbers(int* numbers) {
    for (int i = 0; i < 5; ++i) {
        std::cout << numbers[i];    // OK, вывод - 12345
    }
    for (auto number : numbers) {   // не работает, так как numbers не хранит информацию о размере
        ...
    }
}
int main() {
    int numbers[5] = { 1,2,3,4,5 };
    int* numbers_ptr = numbers;
    PrintNumbers(numbers_ptr);
}

На смену С-массива пришли другие контейнеры из C++:

std::array

Рассмотрим синтаксис объявления данного типа:

std::array<int, 5> numbers = {1, 2, 3, 4, 5};
std::array<int, 5> numbers2 = {1, 2, 3};        //эквивалентно [1, 2, 3, 0, 0]

При этом если теперь заменить в функции PrintNumbers int* на std::array, то все будет работать:

void PrintNumbers(const std::array<int, 5>& numbers) {
    for (auto number : numbers) {
        std::cout << number;
    }
}
int main() {
   std::array<int, 5> numbers = {1, 2, 3, 4, 5};
   PrintNumbers(numbers);
}

Недостаток std::array - он не умеет добавлять элементы и очень неудобно носить с названием размер массива, поэтому рассмотрим более усовершенствованный контейнер

std::vector

std::vector имеет похожий синтаксис с std::array:

void PrintNumbers(const std::vector<int>& numbers) {
    for (auto number : numbers) {
        std::cout << number;
    }
}
int main() {
   std::vector<int> numbers = {1, 2, 3, 4, 5};
   PrintNumbers(numbers);
}

Свойства std::vector:

Рассмотрим примеры работы с итераторами:

std::vector<int>::iterator numbers_begin = numbers.begin();     // - итератор на первый элемент массива
std::cout << *numbers_begin;                                    // вывод - 1
auto numbers_end = numbers.end();                               // - итератор на элемент ЗА последним
for (auto it = numbers.begin(); it < numbers.end(); ++it) {
    std::cout << *it << ' ';                                    // вывод - 1 2 3 4 5
}

У итераторов реализованы операторы +=, -=, –, ++

std::list

std::list - реализация двусвязного списка в C++. В отличие от std::vector, он не имеет возможности получения любого элемента за O(1). Поэтому, чтобы достать i-ый элемент из него, нужно написать это вручную:

std::list<int> numbers = {1, 2, 3, 4, 5};
auto it = numbers.begin();
for (size_t i = 0; i < 2; ++i) {
    ++it;
}
std::cout << *it; // вывод - 3

Так же, мы не можем сравнивать итераторы у std::list, поэтому такой проход компилироваться не будет:

for (auto it = numbers.begin(); it < numbers.end(); ++it) {     //ошибка
    std::cout << *it << ' ';                                    
}

Но у нас есть операторы == и !=

for (auto it = numbers.begin(); it != numbers.end(); ++it) {    работает
    std::cout << *it << ' ';                                    
}

Одно из преимуществ std::list перед std::vector - вставка элемента по итератору в произвольное место за O(1):

std::list<int> numbers = {1, 2, 3, 4, 5};
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
    numbers.insert(it, 0);
}
numbers.push_front(100);
numbers.push_back(101);
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
    std::cout << *it << ' ';                                    // вывод - 100 0 1 0 2 0 3 0 4 0 5 101
}

std::deque

std::deque - двусвязный список буфферов. У std::deque есть и [], и push_back, и push_front, и insert. Пример использования

std::deque<int> numbers = { 1, 2, 3, 4 };
numbers.insert(numbers.begin() + 1, 5);
numbers.push_back(6);
for (auto elem : numbers) {
	std::cout << elem << ' ';               // вывод - 1 5 2 3 4 6
}