12.2 – Классы и члены классов

Добавлено 19 июня 2021 в 09:07

Хотя 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 мы должны использовать класс вместо структуры?

Этот объект содержит как данные-члены, так и функции-члены, поэтому мы должны использовать класс. Мы не должны использовать структуры для объектов, у которых есть функции-члены.

Теги

C++ / CppLearnCppДля начинающихКласс (программирование)ОбучениеОбъектно-ориентированное программирование (ООП)Программирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.