12.2 – Классы и члены классов
Хотя C++ предоставляет ряд базовых типов данных (например, char
, int
, long
, float
, double
и т.д.), которых часто достаточно для решения относительно простых задач, решить сложные задачи, используя только эти типы, может оказаться затруднительно. Одна из наиболее полезных функций C++ – это возможность определять собственные типы данных, которые лучше соответствуют решаемой задаче. Вы уже видели, как для создания ваших собственных типов данных могут использоваться перечислимые типы и структуры.
Вот пример структуры, используемой для хранения даты:
struct DateStruct
{
int year{};
int month{};
int day{};
};
Перечислимые типы и структуры только для данных (структуры, содержащие только переменные) представляют собой традиционный мир не объектно-ориентированного программирования, поскольку они могут содержать только данные. В C++11 мы можем создать и инициализировать эту структуру следующим образом:
DateStruct today { 2020, 10, 14 }; // использовать унифицированную инициализацию
Теперь, если мы хотим вывести дату на экран (что мы, вероятно, очень хотим сделать), имеет смысл написать функцию для этого. Вот полная программа:
#include <iostream>
struct DateStruct
{
int year{};
int month{};
int day{};
};
void print(const DateStruct &date)
{
std::cout << date.year << '/' << date.month << '/' << date.day;
}
int main()
{
DateStruct today { 2020, 10, 14 }; // использовать унифицированную инициализацию
today.day = 16; // использовать оператор выбора члена для выбора члена структуры
print(today);
return 0;
}
Эта программа печатает:
2020/10/16
Классы
В мире объектно-ориентированного программирования мы часто хотим, чтобы наши типы не только хранили данные, но и предоставляли функции, которые работают с этими данными. В C++ это обычно делается с помощью ключевого слова class
. Ключевое слово class
определяет новый пользовательский тип, называемый классом.
В C++ классы и структуры, по сути, одинаковы. Фактически, следующие структура и класс практически идентичны:
struct DateStruct
{
int year{};
int month{};
int day{};
};
class DateClass
{
public:
int m_year{};
int m_month{};
int m_day{};
};
Обратите внимание, что единственное существенное отличие – это ключевое слово public:
в классе. Мы обсудим функцию этого ключевого слова в следующем уроке.
Как и объявление структуры, объявление класса не выделяет памяти. Оно только определяет, как выглядит класс.
Предупреждение
Как и в случае со структурами, одна из самых простых ошибок в C++ – это забыть точку с запятой в конце объявления класса. Это вызовет ошибку компиляции в следующей строке кода. Современные компиляторы, такие как Visual Studio 2010, укажут на то, что вы, возможно, забыли точку с запятой, но более старые или менее сложные компиляторы могут этого не сделать, что может затруднить обнаружение реальной ошибки.
Определения классов (и структур) похожи на план – они описывают, как будет выглядеть результирующий объект, но на самом деле они не создают объект. Чтобы реально создать объект класса, необходимо определить переменную этого типа класса:
DateClass today { 2020, 10, 14 }; // объявляем переменную класса DateClass
Функции-члены
В дополнение к хранению данных классы (и структуры) также могут содержать функции! Функции, определенные внутри класса, называются функциями-членами (или иногда методами). Функции-члены могут быть определены внутри или вне определения класса. Сейчас (для простоты) мы определим их внутри класса, а позже покажем, как определять их вне класса.
Вот наш класс DateClass
с функцией-членом для печати даты:
class DateClass
{
public:
int m_year{};
int m_month{};
int m_day{};
void print() // определяет функцию-член с именем print()
{
std::cout << m_year << '/' << m_month << '/' << m_day;
}
};
Как и для членов структуры, доступ к членам (переменным и функциям) класса осуществляется с помощью оператора выбора члена (.
):
#include <iostream>
class DateClass
{
public:
int m_year{};
int m_month{};
int m_day{};
void print()
{
std::cout << m_year << '/' << m_month << '/' << m_day;
}
};
int main()
{
DateClass today { 2020, 10, 14 };
today.m_day = 16; // использовать оператор выбора члена, чтобы выбрать переменную-член класса
today.print(); // использовать оператор выбора члена для вызова функции-члена класса
return 0;
}
Этот код печатает:
2020/10/16
Обратите внимание, насколько эта программа похожа на версию со структурой, которую мы написали выше.
Однако есть несколько отличий. В версии DateStruct
из приведенного выше примера нам нужно было передать саму структуру в функцию print()
в качестве первого параметра. В противном случае print()
не узнает, какую структуру DateStruct
мы хотим использовать. Поэтому нам пришлось явно указать этот параметр внутри функции.
Функции-члены работают несколько иначе: все вызовы функций-членов должны быть связаны с объектом класса. Когда мы вызываем today.print()
, мы говорим компилятору вызвать функцию-член print()
, связанную с объектом today
.
Теперь давайте снова посмотрим на определение функции-члена print
:
void print() // определяет функцию-член с именем print()
{
std::cout << m_year << '/' << m_month << '/' << m_day;
}
Что на самом деле означают m_year
, m_month
и m_day
? Они относятся к связанному объекту (как определено вызывающим).
Поэтому, когда мы вызываем "today.print()
", компилятор интерпретирует m_day
как today.m_day
, m_month
как today.m_month
и m_year
как today.m_year
. Если бы мы вызвали "tomorrow.print()
", m_day
тогда будет ссылаться на tomorrow.m_day
.
Таким образом, связанный объект, по сути, неявно передается функции-члену. По этой причине его часто называют неявным объектом.
Мы подробнее поговорим о том, как работает передача неявных объектов, в следующем уроке этой главы.
Ключевым моментом здесь является то, что с функциями, не являющимися членами, мы должны передавать данные, с которыми функция будет работать. С функциями-членами мы можем предположить, что у нас всегда есть неявный объект класса, с которым можно работать!
Использование префикса "m_" для переменных-членов помогает отличать переменные-члены от параметров функций или локальных переменных внутри функций-членов. Это полезно по нескольким причинам. Во-первых, когда мы видим присвоение переменной с префиксом "m_", мы знаем, что меняем состояние экземпляра класса. Во-вторых, в отличие от параметров функций или локальных переменных, которые объявляются внутри функции, переменные-члены объявляются в определении класса. Следовательно, если мы хотим знать, как объявляется переменная с префиксом "m_", мы знаем, что должны искать в определении класса, а не внутри функции.
По соглашению имена классов должны начинаться с заглавной буквы.
Лучшая практика
Называйте свои классы, начиная с заглавной буквы.
Вот еще один пример класса:
#include <iostream>
#include <string>
class Employee
{
public:
std::string m_name{};
int m_id{};
double m_wage{};
// Выводим информацию о сотруднике на экран
void print()
{
std::cout << "Name: " << m_name <<
" Id: " << m_id <<
" Wage: $" << m_wage << '\n';
}
};
int main()
{
// Объявляем двух сотрудников
Employee alex { "Alex", 1, 25.00 };
Employee joe { "Joe", 2, 22.25 };
// Распечатать информацию о сотруднике
alex.print();
joe.print();
return 0;
}
Этот код дает следующий результат:
Name: Alex Id: 1 Wage: $25
Name: Joe Id: 2 Wage: $22.25
С обычными функциями, не являющимися членами, функция не может вызывать функцию, которая определена «ниже» (без предварительного объявления):
void x()
{
// Вы не можете вызвать y() отсюда, если компилятор еще
// не видел предварительное объявление для y()
}
void y()
{
}
Для функций-членов это ограничение не действует:
class foo
{
public:
void x() { y(); } // здесь можно вызвать y(), даже если y() определена позже в этом классе
void y() { };
};
Типы членов
Помимо переменных-членов и функций-членов классы могут иметь типы-члены или вложенные типы (включая псевдонимы типов). В следующем примере мы создаем калькулятор, в котором мы можем быстро изменить тип используемого числа, если нам когда-нибудь это понадобится.
#include <iostream>
#include <vector>
class Calculator
{
public:
using number_t = int; // это вложенный псевдоним типа
std::vector<number_t> m_resultHistory{};
number_t add(number_t a, number_t b)
{
auto result{ a + b };
m_resultHistory.push_back(result);
return result;
}
};
int main()
{
Calculator calculator{};
std::cout << calculator.add(3, 4) << '\n'; // 7
std::cout << calculator.add(99, 24) << '\n'; // 123
for (Calculator::number_t result : calculator.m_resultHistory)
{
std::cout << result << '\n';
}
return 0;
}
Вывод программы:
7
123
7
123
В таком контексте имя класса эффективно действует как пространство имен для вложенного типа. Изнутри класса нам нужно ссылаться на него только как на number_t
. Извне класса мы можем получить доступ к типу через Calculator::number_t
.
Когда мы решим, что int
больше не соответствует нашим потребностям, и мы хотим использовать double
, нам нужно обновить только псевдоним типа, а не заменять каждое использование int
на double
.
Члены-псевдонимы типов упрощают сопровождение кода и могут уменьшить количество набора текста. Шаблоны классов, о которых мы поговорим позже, часто используют члены псевдонимы типов. Вы уже видели это как std::vector::size_type
, где size_type
– это псевдоним для unsigned int
.
Вложенные типы не могут быть объявлены предварительно. Как правило, вложенные типы следует использовать только тогда, когда вложенный тип используется исключительно внутри этого класса. Обратите внимание: поскольку классы являются типами, можно вкладывать классы внутрь других классов – это нераспространено и обычно выполняется только опытными программистами.
Замечание о структурах в C++
В C структуры могут содержать только данные и не имеют связанных функций-членов. В C++ после разработки классов (с использованием ключевого слова class
) Бьярн Страуструп потратил некоторое время на размышления о том, следует ли предоставлять структурам (которые были унаследованы от C) возможность иметь функции-члены. Поразмыслив, он решил, что они должны частично иметь единый для обоих набор правил. Поэтому, хотя мы написали приведенные выше программы с использованием ключевого слова class
, мы могли бы вместо этого использовать ключевое слово struct
.
Многие разработчики (включая меня) считают, что это было неправильное решение, поскольку оно может привести к опасным предположениям. Например, справедливо предположить, что класс выполнит очистку после себя (например, класс, который выделяет память, освободит ее перед уничтожением), но небезопасно предполагать, что это сделает и структура. Следовательно, мы рекомендуем использовать ключевое слово struct
для структур, содержащих только данные, и ключевое слово class
для определения объектов, которые требуют объединения как данных, так и функций.
Лучшая практика
Используйте ключевое слово struct
для структур, содержащих только данные. Используйте ключевое слово class
для объектов, которые имеют как данные, так и функции.
Вы уже использовали классы, даже не подозревая об этом
Оказывается, стандартная библиотека C++ полна классов, созданных для вашего удобства. std::string
, std::vector
и std::array
– это всё типы классов! Итак, когда вы создаете объект любого из этих типов, вы создаете экземпляр объекта класса. И когда вы вызываете функцию с использованием этих объектов, вы вызываете функцию-член.
#include <string>
#include <array>
#include <vector>
#include <iostream>
int main()
{
std::string s { "Hello, world!" }; // создание экземпляра объекта класса string
std::array<int, 3> a { 1, 2, 3 }; // создание экземпляра объекта класса array
std::vector<double> v { 1.1, 2.2, 3.3 }; // создание экземпляра объекта класса vector
std::cout << "length: " << s.length() << '\n'; // вызываем функцию-член
return 0;
}
Заключение
Ключевое слово class
в C++ позволяет нам создать пользовательский тип, который может содержать как переменные-члены, так и функции-члены. Классы составляют основу объектно-ориентированного программирования, и мы проведем оставшуюся часть этой главы и многие другие главы, исследуя всё, что они могут предложить!
Небольшой тест
Вопрос 1
a) Создайте класс IntPair
, содержащий два числа int
. В этом классе должны быть две переменные-члены для хранения чисел int
. Вам также следует создать две функции-члены: одну с именем set
, которая позволит вам присваивать значения целым числам, и одну с именем print
, которая будет печатать значения переменных.
Должна выполняться следующая функция main
:
int main()
{
IntPair p1;
p1.set(1, 1); // устанавливаем значения p1 равными (1, 1)
IntPair p2{ 2, 2 }; // инициализируем значения p2 как (2, 2)
p1.print();
p2.print();
return 0;
}
и создаваться следующий вывод:
Pair(1, 1)
Pair(2, 2)
Ответ
#include <iostream> class IntPair { public: int m_first{}; int m_second{}; void set(int first, int second) { m_first = first; m_second = second; } void print() { std::cout << "Pair(" << m_first << ", " << m_second << ")\n"; } }; int main() { IntPair p1; p1.set(1, 1); IntPair p2{ 2, 2 }; p1.print(); p2.print(); return 0; }
b) Почему для IntPair
мы должны использовать класс вместо структуры?
Ответ
Этот объект содержит как данные-члены, так и функции-члены, поэтому мы должны использовать класс. Мы не должны использовать структуры для объектов, у которых есть функции-члены.