C++ — один из старейших языков программирования, созданный еще в 1979 году. Он остается важнейшим языком для современных разработчиков, поскольку он используется для повышения производительности больших систем в таких областях, как разработка видеоигр, операционные системы, браузеры и т. д. как офисное, так и медицинское программное обеспечение.
Легко понять, почему разработчики хотят изучать C++. Если вы уже изучаете C++, вы, вероятно, заметили, что его сложно освоить.
Не беспокойтесь! C++ по-прежнему ценен для изучения, поэтому сегодня мы познакомим вас с некоторыми промежуточными концепциями и примерами C++, чтобы сделать вас на один шаг ближе к освоению этого сложного, но востребованного языка.
Вот темы, которые мы рассмотрим сегодня:
- Использование объектно-ориентированного программирования на C++
- Как работают строки в C++?
- Что такое указатель в C++?
- Что такое массивы в C++?
- Что такое вектор в C++?
- Использование карт C++
- Управление памятью в С++
- Что изучать дальше
Использование объектно-ориентированного программирования на C++
C++ — это объектно-ориентированный язык. Эта парадигма является одним из определяющих отличий от C и C#. В C++ мы достигаем этого, используя объекты и классы для хранения данных и управления ими. Благодаря этому объектно-ориентированному подходу разработчики могут реализовать стратегии наследования, полиморфизма, абстракции и инкапсуляции.
Дополнительную информацию об объектно-ориентированных концепциях и терминах см. в разделе Что такое объектно-ориентированное программирование? Подробное объяснение ООП.
При изучении C++ важно понимать, как максимально использовать его возможности ООП. Ниже мы рассмотрим несколько примеров каждой из основных стратегий ООП: наследование, полиморфизм, абстракция и инкапсуляция.
Использование наследования в C++
Настроить отношения наследования в C++ несложно; мы просто добавляем объявление класса с : [parent name]()
. Это позволяет нам совместно использовать как общедоступные переменные, так и методы от родительского класса к дочернему.
Ниже вы увидите, как мы можем использовать это для создания класса BankAccount
, используя класс Account
.
#include <iostream>
class Account{
public: Account(double b): balance(b){}
void deposit(double amt){ balance += amt; }
void withdraw(double amt){ balance -= amt; }
double getBalance() const { return balance; }
private: double balance;
};
class BankAccount: public Account{
public: // using Account::Account; BankAccount(double b): Account(b){}
void addInterest(){ deposit( getBalance()*0.05 ); } };
int main(){
std::cout << std::endl;
BankAccount bankAcc(100.0); bankAcc.deposit(50.0); bankAcc.deposit(25.15); bankAcc.withdraw(30); bankAcc.addInterest();
std::cout << "bankAcc.getBalance(): " << bankAcc.getBalance() << std::endl;
std::cout << std::endl;
}
--> bankAcc.getBalance(): 152.407
Выше мы создаем два класса: родительский Account
и дочерний BankAccount
. Мы определили общедоступные функции deposit
, withdraw
и getBalance
в Account
, а также addInterest
в BankAccount
.
В нашем основном разделе мы видим, как мы используем все эти функции в нашем новом объекте BankAccount
, bankAcc
, независимо от того, принадлежит ли функция родительскому или дочернему классу.
Использование полиморфизма в C++
Одним из наиболее распространенных применений полиморфизма в C++ является переопределение функций. Имея две функции с одинаковыми именами в разных классах, мы можем создать код, который выполняет различное поведение в зависимости от наследования выбранного объекта.
Ниже мы увидим пример того, как полиморфизм можно использовать для реализации примера функции Draw
в разных классах фигур:
Чтобы сосредоточиться на переопределенных функциях, мы только что заполнили каждый
Draw
простым уникальным операторомcount
. Однако его можно заменить алгоритмом рисования и вести себя так же.
#include <iostream>
class Shape { public: Shape() {} //defining a virtual function called Draw for shape class virtual void Draw() { std::cout << "Drawing a Shape" << std::endl; } };
class Rectangle : public Shape { public: Rectangle() {} //Draw function defined for Rectangle class virtual void Draw() { std::cout << "Drawing a Rectangle" << std::endl; } };
class Triangle : public Shape { public: Triangle() {} //Draw function defined for Triangle class virtual void Draw() { std::cout << "Drawing a Triangle" << std::endl; } };
class Circle : public Shape { public: Circle() {} //Draw function defined for Circle class virtual void Draw() { std::cout << "Drawing a Circle" << std::endl; } };
int main() { Shape* s;
Triangle tri; Rectangle rec; Circle circ;
// store the address of Rectangle s = &rec; // call Rectangle Draw function s->Draw();
// store the address of Triangle s = &tri; // call Triangle Draw function s->Draw();
// store the address of Circle s = ˆ // call Circle Draw function s->Draw();
return 0; }
--> Drawing a Rectangle Drawing a Triangle Drawing a Circle
Выше мы видим родительский класс shape
и три дочерних класса Triangle
, Rectangle
и Circle
. Процесс рисования каждой формы будет отличаться в зависимости от самой формы. Это приводит нас к использованию полиморфизма для определения функции Draw
для каждого из классов.
C++ по умолчанию отдает приоритет локальной функции над родительской функцией Draw
. Это означает, что мы можем вызывать Draw
, не беспокоясь о том, к какому классу формы относится объект. Если это прямоугольник, вывод будет «Рисование прямоугольника». Если это треугольник, будет выведено «Рисование треугольника» и так далее.
Абстракция и инкапсуляция
Абстракция работает в C++ так же, как и в других жестко запрограммированных языках, за счет использования ключевых слов private
и public
. Это похоже на абстракцию, поскольку абстракция скрывает несущественную информацию. Инкапсуляция может использоваться для достижения абстракции с помощью частных функций получения и установки.
Ниже мы увидим, как добиться как абстракции, так и инкапсуляции в C++:
#include <iostream> using namespace std;
class abstraction { private: int a, b;
public:
// public setter function void set(int x, int y) { a = x; b = y; }
// public getter function void display() { cout<<"a = " <<a << endl; cout<<"b = " << b << endl; } };
int main() { abstraction obj;
obj.set(1, 2); obj.display();
return 0; }
--> a = 1 b = 2
Здесь мы сначала создаем класс abstraction
, который инициализирует частные переменные a
и b
. Мы также определяем две общедоступные функции, set
и display
. Это обеспечивает инкапсуляцию путем отделения переменных от внешнего мира, поэтому мы можем получить к ним доступ только через публичные функции. Используя set
, мы затем меняем значения внутри main
. Наконец, мы печатаем переменные, используя display
.
Это гарантирует, что пользователи не могут напрямую изменять переменные, но могут использовать их через функции получения и установки, достигая абстракции, сохраняя сами переменные безопасными и скрытыми от пользователей.
Как работают строки в C++?
Строки в C++ аналогичны строкам в других языках тем, что представляют собой набор упорядоченных символов. Однако в C++ есть два способа создания строк: либо с использованием строк в стиле C, либо с помощью класса C++ string
.
C style strings
— это старомодный способ создания строк в C++. Вместо стандартного строкового объекта C-style strings
состоит из массива символов, заканчивающегося нулем, заканчивающегося специальным символом \0
. Из-за этого скрытого символа строки в стиле C всегда имеют длину на один символ больше, чем видимое количество символов.
Этот размер может быть либо неуказанным для автоматической установки требуемого размера, либо вручную до любого желаемого размера.
char str[] = "Educative"; // automatic length set to 10
char str[10] = "Educative"; // manual set length to 10
Мы также можем создавать строки в C++, используя класс C++ string
, встроенный в стандартную библиотеку C++. Это более популярный метод, так как все управление памятью, ее выделение и нулевое завершение внутренне обрабатываются классом. Еще одним преимуществом является то, что длина строки может быть изменена во время выполнения благодаря динамическому выделению памяти.
В целом, эти изменения делают класс string
более устойчивым к ошибкам и предоставляют множество встроенных функций, таких как append()
, которая добавляет в конец строки, и length()
, которая возвращает количество символов в строке.
Ниже мы узнаем, как использовать подобные функции для выполнения обычных манипуляций со строками.
Как напечатать строку
Мы можем печатать строки в C++, используя глобальный объект cout
вместе с оператором <<
, который предшествует напечатанному содержимому. Мы также включаем глобальный объект endl
, который используется для пропуска строки после операции для лучшей читабельности. Поскольку и endl
, и cout
являются предопределенными объектами глобального класса ostream
, мы должны обязательно включить заголовок <iostream>
в программы, где они необходимы.
Ниже мы можем увидеть, как мы инициализируем и печатаем str1
:
#include <iostream> #include <string> using namespace std; int main() { string str1 ("printed string"); //initializes the string cout << str1 << endl; //prints string return 0; }
--> printed string
Как вычислить длину строки
Чтобы вычислить длину строки, мы можем использовать функции length()
или size()
. Каждый работает одинаково, и каждый существует для удобства чтения. Эти функции также можно использовать для измерения длины контейнеров STL, таких как карты и векторы.
Синонимические функции включены для повышения удобочитаемости, а длина строки интуитивно понятна. Было бы более интуитивно понятно ссылаться на размер массива, чем на его длину.
Ниже мы увидим, как напечатать длину строки str1
с помощью функций length
и size
.
#include <iostream> #include <string> using namespace std;
int main() { string str1 = "Hello"; //initilization
//calculate length cout << "myStr's length is " << str1.length() << endl; cout << "myStr's size is " << str1.size() << endl;
return 0; }
--> myStr's length is 5 myStr's size is 5
Как объединить строку
Эта последняя манипуляция — причудливый способ сказать «склеить две струны». Делая это в C++, мы можем использовать либо оператор +
, либо предопределенную функцию append
, каждая из которых обеспечивает одинаковый эффект.
Для простого тестового кода между этими двумя вариантами почти нет разницы. Однако при использовании в более крупных и сложных программах append
будет работать значительно быстрее, чем +
.
#include <iostream> #include <string> using namespace std; int main() {
string str1= "combined "; string str2 = "strings"; string str3 = str1 + str2; cout << str3 << endl;
//OR
string str4 = str1.append(str2); cout << str4;
}
--> combined strings combined strings
Что такое указатель в C++?
В C++ все переменные должны храниться где-то в памяти главного компьютера. Чтобы помочь программам найти эти переменные без поиска в памяти, C++ позволяет нам использовать специальную переменную, указатели, для явного указания адреса переменной.
Указатели несут две части информации:
- Адрес памяти, хранящийся как значение указателя
- Тип данных, указывающий тип переменной, на которую он указывает
Объявление указателя похоже на объявление стандартной переменной, за исключением того, что перед именем указателя стоит звездочка.
int *ptr;
struct coord *pCrd;
void *vp;
Давайте посмотрим, как это можно использовать ниже:
#include <iostream> using namespace std;
int main () { int val1, val2; int * mypointer;
mypointer = &val1; *mypointer = 10; mypointer = &val2; *mypointer = 20; cout << "firstvalue is " << val1 << '\n'; cout << "secondvalue is " << val2 << '\n'; return 0; }
--> firstvalue is 10 secondvalue is 20
Сначала мы инициализируем две переменные типа int, val1
и val2
, а также указатель типа int, mypointer
. Затем мы устанавливаем mypointer
в адрес val1
с помощью оператора &
. Затем значение mypointer
указывает на 10
. Поскольку mypointer
в настоящее время указывает на адрес val1
, эта операция изменяет значение val1
.
Затем мы повторяем этот процесс, устанавливая mypointer
на адрес val2
и значение в этом месте на 20
.
У указателей в C++ есть два основных преимущества: скорость и использование памяти. Использование указателей сокращает время выполнения, поскольку программы могут быстрее обращаться к значениям, если им заданы прямые адреса памяти.
Шпаргалка по указателям
Поскольку указатели являются одним из самых уникальных элементов C++, может быть сложно запомнить все, что вы можете с ними делать. Для справки, вот наше краткое руководство по основному синтаксису указателя:
Что такое массивы в C++?
Массивы C++ — это набор похожих типов данных, хранящихся под одним именем. Их часто визуализируют как ряд из i
блоков, которые можно выбрать, вызвав индекс блока, чтобы получить доступ к значению, хранящемуся внутри.
Совет. Значения индекса массива начинаются с 0, что означает, что доступ к первому элементу массива можно получить, вызвав элемент
0
, а не1
Длина массива устанавливается (явно или неявно) при объявлении и не может быть изменена без полной переделки массива. Это, однако, делает массив очень эффективной структурой памяти по сравнению с вектором, так как после инициализации массива память не используется.
#include <iostream> using namespace std;
int main() { int arr[5] = {19, 10, 5, 6, 14}; //initializing the array with 5 values cout << "The value of arrr[0], that is, the first value in the array is: " << arr[0] << endl; cout<< "The value of arrr[1], that is, the second value in the array is: " << arr[1] << endl; cout<< "The value of arrr[2], that is, the third value in the array is: " << arr[2] << endl; cout<< "The value of arrr[3], that is, the fourth value in the array is: " << arr[3] << endl; cout<< "The value of arrr[4], that is, the fifth value in the array is: " << arr[4] << endl; int arr2[] = {1,2,3,4}; //we don't specify the size and the compiler assumes a size of 4 }
--> The value of arrr[0], that is, the first value in the array is: 19 The value of arrr[1], that is, the second value in the array is: 10 The value of arrr[2], that is, the third value in the array is: 5 The value of arrr[3], that is, the fourth value in the array is: 6 The value of arrr[4], that is, the fifth value in the array is: 14
Как найти длину массива в С++
В отличие от контейнеров и строк STL, мы не можем найти длину массива, используя length
или size
. Вместо этого мы используем либо оператор sizeof()
, либо арифметику указателей.
Давайте посмотрим, как мы можем использовать каждый из них, начиная с sizeof
:
#include <iostream> using namespace std;
int main() { int arr[] = {1,2,3,4,5,6}; int arrSize = sizeof(arr)/sizeof(arr[0]); cout << "The size of the array is: " << arrSize; return 0; }
--> The size of the array is: 6
В отличие от функции length
, sizeof
фактически возвращает количество байтов, которое выбранный объект занимает в памяти. Размер каждого элемента массива варьируется от массива к массиву, поэтому мы не можем предположить, что это всего один байт.
Чтобы обойти это, мы используем каждый элемент в одном массиве и будем использовать одинаковый объем памяти. Следовательно, если мы разделим общее количество байтов, используемых массивом, sizeof(arr)
, на количество байтов, используемых первым элементом, sizeof(arr[0])
, мы получим количество элементов в массиве.
Другой способ найти размер — использовать арифметику указателя:
#include <iostream> using namespace std;
int main() { int arr[] = {1,2,3,4,5,6}; int arrSize = *(&arr + 1) - arr; cout << "The size of the array is: " << arrSize; return 0; }
--> The size of the array is: 6
Аналогичного эффекта можно добиться, если учесть, что размер массива равен разнице между адресом конечного элемента и адресом первого элемента массива.
Вот пошаговая разбивка:
(&arr + 1)
указывает на адрес памяти сразу после конца массива.(&arr + 1)
просто преобразует вышеуказанный адрес вint *
.- Вычитание адреса начала массива из адреса конца массива дает длину массива.
Что такое вектор в C++?
Векторы C++ — это контейнеры STL, которые действуют как более совершенная версия строковых массивов. Они упрощают процесс вставки и удаления значений за счет использования большего объема памяти. Векторы хранят элементы непрерывно, поэтому элементы хранятся в памяти рядом друг с другом. В отличие от массивов, векторы являются динамическими, поэтому их размер может изменяться по запросу и может быть пройден с помощью итераторов, таких как begin()
(для начала вектора) и end()
(для конца вектора).
Векторы лучше всего подходят для ситуаций, когда вы будете регулярно добавлять или вычитать значения, а память в значительной степени доступна.
Во-первых, давайте посмотрим, как использовать функцию resize
для активации удобных динамических возможностей вектора:
#include <iostream> #include <vector> using namespace std;
int main() { vector<int> numbers;
numbers.resize(7); cout<<numbers.size()<<endl; numbers.resize(4); cout<<numbers.size(); }
--> 7 4
Resize устанавливает максимальное количество элементов в векторе на указанное число. Выше мы сначала инициализируем вектор numbers
, затем устанавливаем его размер с помощью resize(7)
. Скажем, мы тогда понимаем, что мы вырежем 3 из 7 элементов. Мы бы использовали resize(4)
, чтобы обрезать лишние 3 элемента, чтобы неиспользуемые элементы не загромождали вектор.
Ниже мы увидим, как создать вектор, использовать функцию push_back
для добавления значений и функции begin
и end
для печати:
#include <iostream> #include <vector> using namespace std;
int main() { vector<int> v; // Vector's Implementation
// Inserting Values in Vector v.push_back(1); v.push_back(2); v.push_back(3); v.push_back(4); v.push_back(5);
cout << "Output from begin to end: "; for (auto i = v.begin(); i != v.end(); ++i) cout << *i << " "; }
--> Output from begin to end: 1 2 3 4 5
Совет. Размеры векторов автоматически изменяются в соответствии с новыми элементами при использовании
push_back
илиinsert
.
В C++ мы должны сначала инициализировать вектор v
, а затем заполнить его значениями. Мы хотим, чтобы значения шли в конце, а не в начале, поэтому мы используем функцию push_back
, которая вставляет новый элемент в конец. Оттуда мы печатаем каждое значение в векторе между его началом, выбранным с помощью функции begin
, и концом, выбранным с помощью функции end
.
Использование карт C++
Карты — это тип контейнера, в котором хранятся элементы с использованием пар ключ-значение. Каждый элемент на карте имеет уникальный ключ в качестве идентификатора и значение, которое извлекается при вызове ключа. Ключи автоматически сортируются от меньшего к большему, что делает поиск карт очень быстрым.
При инициализации должны быть заданы типы данных ключа и значения, а также имя объекта карты.
Совет. Целые числа являются наиболее распространенным типом ключа, используемого с картами; однако они могут быть и других типов.
Наиболее важными функциями, используемыми с картами, являются insert()
, которая добавляет новый элемент на карту, и find()
, которая извлекает элемент с соответствующим ключом и возвращает его значение.
Ниже мы видим обе эти функции в действии с нашей картой Employees
:
#include <string.h> #include <iostream> #include <map> #include <utility> using namespace std;
int main() { // Initializing a map with integer keys // and corresponding string values map<int, string> Employees;
//Inserting values in map using insert function Employees.insert ( std::pair<int, string>(101,"Aaron") ); Employees.insert ( std::pair<int, string>(102,"Amanda") ); Employees.insert ( std::pair<int, string>(105,"Ryan") );
// Finding the value corresponding to the key '102' std::map<int, string>::iterator it = Employees.find(102); if (it != Employees.end()){ std::cout <<endl<< "Value of key = 102 => " << Employees.find(102)->second << '\n'; } }
--> Value of key = 102 => Amanda
Карты полезны для хранения данных, которые будут часто запрашиваться, и хорошо разбиваются на ассоциативную структуру, такую как реестр электронной почты компании.
Как отсортировать карту по значению в C++
Как сказано выше, карты сортируются по ключу автоматически. Однако вместо этого может быть полезно сортировать по значению. Мы можем сделать это, скопировав элементы в вектор пар ключ-значение, а затем отсортировав вектор перед окончательной печатью.
Давайте посмотрим, что в действии:
#include <iostream> #include <map> #include <vector> #include <algorithm> // for sort function
using namespace std;
// utility comparator function to pass to the sort() module bool sortByVal(const pair<string, int> &a, const pair<string, int> &b) { return (a.second < b.second); }
int main() { // create the map map<string, int> mymap = { {"coconut", 10}, {"apple", 5}, {"peach", 30}, {"mango", 8} };
cout << "The map, sorted by keys, is: " << endl; map<string, int> :: iterator it; for (it=mymap.begin(); it!=mymap.end(); it++) { cout << it->first << ": " << it->second << endl; } cout << endl;
// create a empty vector of pairs vector<pair<string, int>> vec;
// copy key-value pairs from the map to the vector map<string, int> :: iterator it2; for (it2=mymap.begin(); it2!=mymap.end(); it2++) { vec.push_back(make_pair(it2->first, it2->second)); }
// // sort the vector by increasing order of its pair's second value sort(vec.begin(), vec.end(), sortByVal);
// print the vector cout << "The map, sorted by value is: " << endl; for (int i = 0; i < vec.size(); i++) { cout << vec[i].first << ": " << vec[i].second << endl; } return 0; }
--> The map, sorted by keys, is: apple: 5 coconut: 10 mango: 8 peach: 30
The map, sorted by value is: apple: 5 mango: 8 coconut: 10 peach: 30
Управление памятью в С++
Одним из самых уникальных аспектов C++ является необходимость явного выделения памяти кучи во время выполнения, процесс, называемый динамическим выделением памяти. Это автоматически обрабатывается компилятором в других языках, таких как Java и JavaScript, что дает программисту меньше контроля в пользу простоты.
Чтобы выделить память в C++, мы используем оператор new
для переменной и new[]
для массива. Каждый возвращает адрес памяти, где эти данные будут храниться. Мы можем использовать это в сочетании с указателями, которые мы обсуждали ранее, чтобы затем присвоить значение этому адресу.
Посмотрите, как оператор new
и указатели используются вместе ниже:
// declares an int pointer int* var;
// allocate memory for variable // using the new keyword var = new int;
// assign value to allocated memory *var = 45;
C++ не имеет системы автоматической сборки мусора, а это означает, что вы должны вручную освобождать память от указателей, когда они больше не нужны. Для этого мы используем ключевое слово delete
, которое освобождает память для данной переменной или контейнера.
int* var;
var = new int;
delete var;
Если мы забудем освободить память, мы получим утечку памяти из-за того, что неиспользуемые указатели все еще удерживают выделенную память.
Отслеживание выделений и освобождений
До сих пор в нашем примере было одно new
и одно delete
, тогда как в практических программах их может быть десятки. В более крупном масштабе сложнее понять, удалили ли вы все ненужные указатели или найти эти неиспользуемые указатели. Чтобы выполнить простую проверку, мы можем просто подсчитать количество new
распределений и сравнить их с количеством delete
освобождений.
#include "myNew.hpp" // #include "myNew2.hpp" // #include "myNew3.hpp"
#include <iostream> #include <string>
class MyClass{ float* p= new float[100]; };
class MyClass2{ int five= 5; std::string s= "hello"; };
int main(){
int* myInt= new int(1998); double* myDouble= new double(3.14); double* myDoubleArray= new double[2]{1.1,1.2};
MyClass* myClass= new MyClass; MyClass2* myClass2= new MyClass2;
delete myDouble; delete [] myDoubleArray; delete myClass; delete myClass2;
getInfo();
}
--> Number of allocations: 6 Number of deallocations: 4
Здесь мы видим, что у нас есть 6 распределений, но только 4 освобождения, что означает, что 2 наших указателя все еще занимают память. Хотя он не сообщает нам, где находятся эти неверные указатели, он указывает в правильном направлении, если мы полностью очистили свою программу.
Внимание к управлению памятью является ключом к успеху разработчика C++, поскольку в больших и сложных программах гораздо больше возможностей для неправильного управления памятью. Если оставить эти проблемы нерешенными, они могут снизить производительность и даже вызвать сбои в работе программы.
Совет. Контейнеры STL и строки C++ автоматически управляют выделением памяти. Попробуйте использовать больше из них, чтобы избежать утечек памяти или необходимости микроуправления каждой переменной/контейнером.
Что изучать дальше
Поздравляю! Теперь вы изучили некоторые базовые понятия C++, которые сделают вас опытным разработчиком C++, которого можно нанять.
Как мы уже говорили ранее, C++ — сложный язык для освоения даже для тех, кто имеет опыт работы с другими языками. Однако, как только вы поближе познакомитесь с его расширенными возможностями, вы обнаружите, что C++ предоставляет вам непревзойденный уровень контроля над часто упрощенными функциями, такими как выделение памяти.
По мере того, как вы продолжаете свое путешествие по изучению C++, вот некоторые дополнительные темы, которые стоит проверить в следующий раз:
- Умные указатели
- Перемещение и копирование семантики
- Абстрактные базовые классы
- Виртуальные методы
- Шаблоны
Новый курс обучения Grokking Coding Interview Patterns in C+ от Educative проведет вас по этим сложным темам и многим другим, используя текстовые интерактивные уроки и практические задачи. Эти модули ориентированы на карьеру и содержат материалы, которые вам понадобятся в работе. Все они написаны опытными разработчиками C++.
Удачного обучения!
Продолжить чтение о C++ на Educative
- C++ — хороший первый язык для изучения
- Учебник по современной многопоточности и параллелизму в C++
- Значит, ты знаешь С++. Теперь пришло время изучить стандартную библиотеку
Начать обсуждение
Как вы думаете, почему изучение C++ — хорошая идея для молодых разработчиков? Была ли эта статья полезна? Дайте нам знать в комментариях ниже!