Daily bit(e) of C++ #104 , Современный курс C++ (включая C++23), часть 3 из N
Добро пожаловать на третий урок из серии Learn Modern C++. Сегодня мы пройдем ускоренный курс по типам.
Если вы пропустили предыдущий урок, посмотрите его здесь:
Одним из отличительных аспектов C++ является то, что он рассматривает библиотечные типы (включая определяемые пользователем) как встроенные типы. Например, библиотечные типы могут определять, что происходит, когда одна переменная назначается другой, или как работают сравнения и арифметические операции.
Обратите внимание, что существуют тонкие поведенческие различия между встроенными и библиотечными типами, но вам не о чем беспокоиться в этом курсе и даже в промежуточных случаях использования.
Если вы выполнили домашнюю работу к предыдущему уроку, вы уже встречались с некоторыми базовыми типами, и я даже упомянул один из библиотечных типов (std::vector) в одном из примеров.
Буль
bool — это логический тип, имеющий только два допустимых значения: true и false.
True сопоставляется с 1, а false сопоставляется с 0. Такое поведение часто используется для различных трюков, которых вам следует избегать. Явный код всегда лучше.
#include <iostream> bool x = true; // std::boolalpha is a manipulator that sets the stream // (in this case std::cout) to handle boolean values // as true/false, not the corresponding integral values 1/0 std::cout << std::boolalpha << "x == " << x << "\n"; // the setting persists until unset std::cout << std::noboolalpha << "x == " << x << "\n"; // Good example of boolean expressions are basic comparisons: bool y = 3 < 1; // y == false // Do not rely on implicit conversions between bool // and integral types (but since you may run into it): int a = 42; bool z = a; // same as bool z = (a != 0); // z == true // You might also come across this trick: int b = !!a; // double negation and then coversion to integral turns // any non-zero value to 1
Откройте пример в Compiler Explorer.
Целочисленные типы
Целочисленные типы охватывают целые числа со знаком: int и целые числа без знака: unsigned int (или просто «без знака»). Вы уже видели оба на предыдущем уроке.
Целочисленные типы могут быть дополнительно изменены модификаторами: «short», «long» и «long long», создавая иерархию типов с увеличением количества битов. В этом курсе мы будем использовать целочисленный тип фиксированной ширины: int64_t.
Если вас интересует разрядность различных целочисленных типов, эта страница предлагает отличный обзор: https://en.cppreference.com/w/cpp/language/types#Properties.
Хотя технически это необязательный тип, тип int64_t будет отображаться на 64-битный целочисленный тип со знаком вашей локальной платформы.
#include <cstdint> // int64_t comes from the cstdint header int64_t a = 10;
Откройте пример в Compiler Explorer.
Плавающая запятая
Наконец, C++ предлагает три типа с плавающей запятой: float, double и long double. Кодировка соответствует стандарту IEEE-754, где поддерживается (большинство платформ).
#include <iostream> #include <iomanip> double x = 4.2; // Default output skips trailing zeroes std::cout << "x == " << x << "\n"; // output: "x == 4.2" // Not all decimal numbers can be precisely represented // by binary floating point representation. float y = 0.1; // Number of displayed decimal digits can be controlled through // the std::setprecision manipulator. std::cout << std::setprecision(10); // std::fixed for fixed-width output std::cout << std::fixed << "y == " << y << "\n"; // output: "y == 0.1000000015" // std::scientific for scientific notation std::cout << std::scientific << "y == " << y << "\n"; // output: "y == 1.0000000149e-01"
Откройте пример в Compiler Explorer.
Массивы
Для массивов мы должны перейти к нашему первому типу библиотеки: std::vector. Поскольку std::vector — это шаблон класса, нам нужно ввести новый синтаксис. Шаблоны фактически являются параметризованными типами. В данном случае нас интересует тип элемента.
#include <vector> #include <cstdint> // An array of integers, initialized with five elements: std::vector<int64_t> data{1,2,3,4,5}; // Elements can be accessed using the operator [] // data[3] == 4 // An array of arrays of integers follows the same pattern std::vector<std::vector<int64_t>> second_dimension{ {1,2,3},{2,3},{4,5,6},{7}}; // Same logic for accessing elements // second_dimension[1][0] == 2 // second_dimension[2][2] == 6 // We can use element access to mutate the array second_dimension[3] = {1,2,3,4,5}; second_dimension[1][0] = 42;
Откройте пример в Compiler Explorer.
Обратите внимание, что доступ к несуществующим элементам делает вашу программу искаженной, поэтому всегда проверяйте правильность индекса при доступе к элементам по индексу.
Именно из-за подобных ошибок мы тестируем домашнюю работу с конфигурациями «addrsan» и «ubsan», которые включают в себя дезинфицирующее средство адреса и дезинфицирующее средство неопределенного поведения. Например, средство очистки адресов обнаружит ошибку такого типа, и вывод будет выглядеть примерно так:
#include <vector> #include <cstdint> std::vector<int64_t> data{1,2,3,4,5}; // C-style for loop for (int64_t idx = 0; idx < std::ssize(data); ++idx) { // access data[idx] } // range-for loop for (int64_t v : data) { // access v } // Example of bad access caught by address sanitizier -> data[5] = 0; /* With errors, always start at the top. The important part here is: WRITE of size 8 at 0x604000000038 thread T0 #0 0x4015ff in main /app/example.cpp:17 That is, we have an invalid write at line 17 in this file. */
Открыть пример в Compiler Explorer.
Как я уже упоминал, библиотечные типы могут определять поведение операторов (таких как оператор доступа к элементам); кроме того, они также могут обеспечивать операции манипулирования и запросов через функции-члены:
#include <vector> #include <cstdint> // Empty array std::vector<int64_t> data; // data.empty() == true, std::ssize(data) == 0 // Add new element to the back of the array. data.push_back(42); // data.empty() == false, std::ssize(data) == 1 // Remove an element from the back of the array. data.pop_back(); // data.empty() == true, std::ssize(data) == 0 data = {1, 2, 3}; // Resize an array to the specified size (5) // setting new elements to the provided value (0) data.resize(5,0); // data == {1, 2, 3, 0, 0} // Remove all elements data.clear(); // data.empty() == true, std::ssize(data) == 0 std::vector<int64_t> a{1,2}; std::vector<int64_t> b{1,2,3}; std::vector<int64_t> c{4,5}; // Lexicographical comparison // a < b, b < c (also ==, !=, >, <=, >=)
Открыть пример в Compiler Explorer.
std::vector — это стандартный тип, который вы будете использовать постоянно. Поэтому я пропущу и упомяну важный аспект инвалидации std::vector: iterator.
Когда мы помещаем новые элементы в std::vector, может потребоваться увеличить внутреннюю память. Видимый побочный эффект заключается в том, что мы больше не используем итераторы, полученные до этого перераспределения, а цикл for-range полагается на итераторы для своей внутренней реализации.
Важный вывод — избегать изменения массива при его повторении. Помимо риска обращения к недопустимой памяти, также обычно сложно рассуждать о циклах, которые изменяют структуру, по которой они перебираются.
#include <vector> #include <cstdint> int main() { std::vector<int64_t> data{1,2,3,4,5}; for (int64_t v : data) { data.push_back(v); // BOOM } } /* ERROR: AddressSanitizer: heap-use-after-free READ of size 8 at 0x604000000018 thread T0 #0 0x40155b in main /app/example.cpp:7 */
Открыть пример в Compiler Explorer.
станд::массив
В редких случаях сложность std::vector может оказаться слишком большой. В таком случае мы можем использовать статический размер std::array.
Второй основной вариант использования std::array — это программирование во время компиляции, особенно когда нам нужно передать массивы из части кода во время компиляции в часть во время выполнения; однако это сложная тема.
#include <array> #include <cstdint> // Two template arguments, type of elements and number of elements std::array<int64_t,5> data{1,2,3,4,5}; // We get a similar interface to std::vector // however, since std::array is statically sized // no push/pop operations data[3] = 42; // data == {1, 2, 3, 42, 5}
Открыть пример в Compiler Explorer.
Струны
Строки в C++ — довольно сложная тема. В этом курсе мы будем использовать строки ASCII.
C++ поддерживает Unicode; однако правильная обработка Unicode (даже с языковой поддержкой) является сложной темой, и вывод в формате Unicode C++23 еще не реализован в компиляторах.
Строки ASCII имеют тип std::string. Он предлагает тот же интерфейс, что и std::vector, но с дополнительными операциями, характерными для работы со строками:
#include <string> std::string greeting = "Hello!"; // Same supported operations as std::vector greeting.pop_back(); // greeting == "Hello" // Append greeting += " World!"; // greeting == "Hello World!" // Prefix and suffix queries if (greeting.starts_with("Hello")) { } if (greeting.ends_with("World!")) { } // Content query if (greeting.contains("or")) { } // Substring: starting index, number of characters std::string food = greeting.substr(0,5); // food == Hello food[0] = 'J'; // Element manipulation, same as std::vector // character type is char, its literals are single-quoted // food == Jello
Открыть пример в Compiler Explorer.
уголь
Я пропустил тип символа, когда обсуждал основные типы, так как вы не будете использовать его изолированно. Однако вы должны знать об этом, так как это тип элемента std::string.
#include <string> #include <cstdint> char x = '?'; // Single character (in this case questionmark) char y = '\n'; // special characters use a backslash (newline) char a = '\''; // If we want to represent a single quotation, we need // to escape it using a backslash std::string b = "\""; // Similar for string, we need to escape // a double quotation std::string text = "The quick brown fox jumps over the lazy dog"; for (int64_t idx = 0; idx < std::ssize(text); ++idx) { // iterate over all characters in the string // text[idx] is of type char } for (char c : text) { // iterate over all characters in the string }
Открыть пример в Compiler Explorer.
Типы пользователей: агрегаты
Мы закончим сегодняшний урок с первой категорией типов пользователей. Агрегаты (что неудивительно) объединяют несколько переменных в одну сущность.
Несмотря на простоту, этот тип пользователя удобен, когда мы хотим сгруппировать определенные данные и рассматривать их как единое целое.
#include <cstdint> #include <vector> // Introduces a new type named "Position" struct Position { int64_t x; // first member named x of type int64_t int64_t y; // second member named y of type int64_t }; Position pos{10,22}; // pos == {10, 22}, that is: pos.x == 10, pos.y == 22 int64_t a = pos.x; // access the member // a == 10 // Array of positions std::vector<Position> positions{{1,2}, {4,4}, {5,3}, {-7,-9}}; // e.g. positions[2].y == 3
Открыть пример в Compiler Explorer.
Шаблоны
Сегодня мы видели несколько шаблонов, так что давайте кратко окунемся в эту тему и посмотрим, как вы можете создать шаблонный агрегатный тип.
#include <vector> #include <cstdint> template <typename A, typename B> struct Custom { A x; std::vector<B> y; }; Custom<int64_t, double> a; // a.x (type int64_t) // a.y (type std::vector<double>) a.x = 10; a.y = {2.4, 0.3, -4.7777};
Открыть пример в Compiler Explorer.
Шаблоны пригодятся при создании универсальных типов, собирающих структурную информацию. Например, операции над массивом ведут себя одинаково независимо от типа элемента (с некоторыми предостережениями экспертов C++).
Пример с комментариями
В сегодняшнем примере с комментариями мы найдем путь в лабиринте, используя поиск в глубину. Если вы перейдете в Compiler Explorer, вы сможете поиграть с вводом лабиринта и изучить, как изменяется вывод.
#include <cstdint> #include <vector> #include <string> #include <iostream> // Aggregate to represent a position struct Position { int64_t row; int64_t col; }; // Maze representation std::vector<std::string> maze{ "#S#################", "# #", "# ############### #", "# # # #", "# # ############# #", "# # # #", "# # ############# #", "# # #", "# #################", "# #", "#### ############ #", "# # #", "######## # ########", "# # #", "#################E#", }; // Initial scan, find start and end, and print the maze: std::cout << "Searching for a path in this maze:\n"; Position start, end; for (int64_t row = 0; row < std::ssize(maze); ++row) { for (int64_t col = 0; col < std::ssize(maze[row]); ++col) { if (maze[row][col] == 'S') start = {row, col}; if (maze[row][col] == 'E') end = {row, col}; std::cout << maze[row][col]; } std::cout << "\n"; } std::cout << "\n"; // Find a path through the maze using DFS // We use a std::vector as a stack data structure std::vector<Position> stack; // We start at start stack.push_back(start); // Change the end into an empty space so we don't have // to check maze[row][col] == ' ' || maze[row][col] == 'E' maze[end.row][end.col] = ' '; while (!stack.empty()) { // Grab the top element from the stack Position pos = stack.back(); // If it is the end, we are done if (pos.row == end.row && pos.col == end.col) break; // Try to go up (taking care of valid indexes) if (pos.row != 0 && maze[pos.row-1][pos.col] == ' ') { // Mark as visited maze[pos.row-1][pos.col] = '.'; // Add it to the stack stack.push_back({pos.row-1, pos.col}); // Jump to the next loop iteration, exteding the path // from {pos.row-1,pos.col} continue; } // Same as above, but left if (pos.col != 0 && maze[pos.row][pos.col-1] == ' ') { maze[pos.row][pos.col-1] = '.'; stack.push_back({pos.row, pos.col-1}); continue; } // Same as above, but down if (pos.row + 1 < std::ssize(maze) && maze[pos.row+1][pos.col] == ' ') { maze[pos.row+1][pos.col] = '.'; stack.push_back({pos.row+1, pos.col}); continue; } // Same as above, but right if (pos.col + 1 < std::ssize(maze[pos.row]) && maze[pos.row][pos.col+1] == ' ') { maze[pos.row][pos.col+1] = '.'; stack.push_back({pos.row, pos.col+1}); continue; } // We are on a space which is a dead-end, // remove it from the stack stack.pop_back(); } // The stack is empty, meaning that all reachable spaces // are dead-ends, i.e. there is no path if (stack.empty()) { std::cout << "No path was found.\n"; return 0; } std::cout << "Found a path, drawn using '@'," " '.' represent visited spaces:\n"; // The stack has our full path from start to end while (!stack.empty()) { // Grab the tail of the path Position pos = stack.back(); // Draw it onto the maze maze[pos.row][pos.col] = '@'; // Remove from stack stack.pop_back(); } // Draw the maze for (int64_t row = 0; row < std::ssize(maze); ++row) { for (int64_t col = 0; col < std::ssize(maze[row]); ++col) std::cout << maze[row][col]; std::cout << "\n"; }
Открыть пример в Compiler Explorer.
Домашнее задание
Репозиторий шаблонов с домашними заданиями для этого урока находится здесь: https://github.com/HappyCerberus/daily-bite-course-03.
Как и во всех домашних заданиях, вам понадобятся VSCode и Docker, установленные на вашем компьютере, и следуйте инструкциям из первого урока.
Цель состоит в том, чтобы все тесты прошли, как описано в файле readme.