12.15 – Дружественные функции и классы
Большую часть этой главы мы проповедовали достоинства сохранения скрытости ваших данных. Однако иногда вы можете столкнуться с ситуациями, когда обнаружите, что у вас есть классы и функции вне этих классов, которые должны очень тесно работать друг с другом. Например, у вас может быть класс, в котором хранятся данные, и функция (или другой класс), которая отображает эти данные на экране. Хотя класс хранилища и код отображения были разделены для упрощения поддержки, код отображения на самом деле тесно связан с деталями класса хранилища. Следовательно, скрытие сведений о классах хранения от кода отображения не дает особой выгоды.
В подобных ситуациях есть два варианта:
- Использовать в коде отображения открытые функции класса хранилища. Однако у этого есть несколько потенциальных недостатков. Во-первых, эти открытые функции-члены должны быть определены, что требует времени и может загромождать интерфейс класса хранилища. Во-вторых, классу хранилища, возможно, придется предоставить для кода отображения функции, которые он не хочет делать доступными для кого-либо еще. Но невозможно сказать «эта функция предназначена для использования только классом отображения».
- В качестве альтернативы, используя дружественные классы и дружественные функции, вы можете предоставить своему коду отображения доступ к закрытым деталям класса хранилища. Это позволяет коду отображения напрямую обращаться ко всем закрытым членам и функциям класса хранилища, не давая при этом доступ кому-либо еще! В этом уроке мы подробнее рассмотрим, как это делается.
Дружественные функции
Дружественная функция – это функция, которая может получить доступ к закрытым членам класса, как если бы она была членом этого класса. Во всем остальном дружественная функция похожа на обычную функцию. Дружественная функция может быть либо обычной функцией, либо функцией-членом другого класса. Чтобы объявить дружественную функцию, просто используйте ключевое слово friend
перед прототипом функции, которую вы хотите сделать другом класса. Не имеет значения, объявляете ли вы дружественную функцию в закрытом или открытом разделе класса.
Вот пример использования дружественной функции:
class Accumulator
{
private:
int m_value;
public:
Accumulator() { m_value = 0; }
void add(int value) { m_value += value; }
// Сделаем функцию reset() другом этого класса
friend void reset(Accumulator &accumulator);
};
// reset() теперь является другом класса Accumulator
void reset(Accumulator &accumulator)
{
// и может получить доступ к закрытым данным объектов Accumulator
accumulator.m_value = 0;
}
int main()
{
Accumulator acc;
acc.add(5); // добавляем 5 в накапливающий сумматор
reset(acc); // сбрасываем накапливающий сумматор в 0
return 0;
}
В этом примере мы объявили функцию с именем reset()
, которая принимает объект класса Accumulator
и устанавливает значение m_value
равным 0. Поскольку reset()
не является членом класса Accumulator
, обычно reset()
не будет иметь доступ к закрытым членам Accumulator
. Однако, поскольку Accumulator
специально объявил эту функцию reset()
как друга класса, то ей предоставляется доступ к закрытым членам Accumulator
.
Обратите внимание, что мы должны передать в reset()
объект Accumulator
. Это потому, что reset()
не является функцией-членом. У нее нет указателя *this
и нет объекта Accumulator
для работы, если он не указан.
Вот еще один пример:
class Value
{
private:
int m_value;
public:
Value(int value) { m_value = value; }
friend bool isEqual(const Value &value1, const Value &value2);
};
bool isEqual(const Value &value1, const Value &value2)
{
return (value1.m_value == value2.m_value);
}
В этом примере мы объявляем функцию isEqual()
другом класса Value
. isEqual()
принимает в качестве параметров два объекта Value
. Поскольку isEqual()
является другом класса Value
, она может получить доступ к закрытым членам всех объектов Value
. В этом случае она использует этот доступ для сравнения двух объектов и возвращает true
, если они равны.
Хотя оба приведенных выше примера довольно надуманы, последний пример очень похож на случаи, с которыми мы столкнемся позже, когда будем обсуждать перегрузку операторов!
Несколько друзей
Функция может быть другом для более чем одного класса одновременно. Например, рассмотрим следующий пример:
#include <iostream>
class Humidity;
class Temperature
{
private:
int m_temp;
public:
Temperature(int temp=0) { m_temp = temp; }
friend void printWeather(const Temperature &temperature,
const Humidity &humidity);
};
class Humidity
{
private:
int m_humidity;
public:
Humidity(int humidity=0) { m_humidity = humidity; }
friend void printWeather(const Temperature &temperature,
const Humidity &humidity);
};
void printWeather(const Temperature &temperature, const Humidity &humidity)
{
std::cout << "The temperature is " << temperature.m_temp <<
" and the humidity is " << humidity.m_humidity << '\n';
}
int main()
{
Humidity hum(10);
Temperature temp(12);
printWeather(temp, hum);
return 0;
}
В этом примере стоит отметить две вещи. Во-первых, поскольку printWeather
является другом обоих классов, она может получить доступ к закрытым данным из объектов обоих классов. Во-вторых, обратите внимание на следующую строку в верхней части примера:
class Humidity;
Это прототип класса, который сообщает компилятору, что в будущем мы собираемся определить класс под названием Humidity
. Без этой строки компилятор при синтаксическом анализе прототипа для printWeather()
внутри класса Temperature
сообщил бы нам, что не знает, что такое Humidity
. Прототипы классов выполняют ту же роль, что и прототипы функций – они сообщают компилятору, как что-то выглядит, чтобы его можно было использовать сейчас и определить позже. Однако, в отличие от функций, классы не имеют возвращаемых типов или параметров, поэтому прототипы классов всегда представляют собой просто class ClassName
, где ClassName
– это имя класса.
Дружественные классы
Также целый класс можно сделать другом другого класса. Это дает всем членам дружественного класса доступ к закрытым членам другого класса. Вот пример:
#include <iostream>
class Storage
{
private:
int m_nValue;
double m_dValue;
public:
Storage(int nValue, double dValue)
{
m_nValue = nValue;
m_dValue = dValue;
}
// Сделаем класс Display другом Storage
friend class Display;
};
class Display
{
private:
bool m_displayIntFirst;
public:
Display(bool displayIntFirst) { m_displayIntFirst = displayIntFirst; }
void displayItem(const Storage &storage)
{
if (m_displayIntFirst)
std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
else // сначала отображаем double
std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
}
};
int main()
{
Storage storage(5, 6.7);
Display display(false);
display.displayItem(storage);
return 0;
}
Поскольку класс Display
является другом Storage
, любой из членов Display
, использующих объект класса Storage
, может напрямую обращаться к закрытым членам Storage
. Эта программа дает следующий результат:
6.7 5
Несколько дополнительных замечаний о дружественных классах. Во-первых, даже несмотря на то, что Display
является другом Storage
, Display
не имеет прямого доступа к указателю *this
объектов Storage
. Во-вторых, то, что Display
является другом Storage
, не означает, что Storage
также является другом Display
. Если вы хотите, чтобы два класса дружили друг с другом, они оба должны объявить друг друга друзьями. Наконец, если класс A
является другом B
, а B
– другом C
, это не означает, что A
является другом C
.
Будьте осторожны при использовании дружественных функций и классов, потому что это позволяет дружественной функции или классу нарушать инкапсуляцию. Если детали реализации класса изменятся, то детали реализации друга также должны будут быть изменены. Следовательно, ограничьте использование дружественных функций и классов до минимума.
Дружественные функции-члены
Вместо того чтобы делать другом весь класс, вы можете сделать другом только одну функцию-член. Это делается аналогично тому, как сделать дружественной обычную функцию, за исключением использования имени функции-члена с включенным префиксом ИмяКласса::
(например, Display::displayItem
).
Однако на самом деле это может быть немного сложнее, чем ожидалось. Давайте, преобразуем предыдущий пример, чтобы сделать Display::displayItem
дружественной функцией-членом. Вы можете попробовать сделать что-то вроде этого:
class Display; // предварительное объявление для класса Display
class Storage
{
private:
int m_nValue;
double m_dValue;
public:
Storage(int nValue, double dValue)
{
m_nValue = nValue;
m_dValue = dValue;
}
// Сделаем функцию-член Display::displayItem другом класса Storage
friend void Display::displayItem(const Storage& storage);
// ошибка: Storage не увидел полное определение класса Display
public:
Display(bool displayIntFirst) { m_displayIntFirst = displayIntFirst; }
void displayItem(const Storage &storage)
{
if (m_displayIntFirst)
std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
else // display double first
std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
}
};
Однако оказывается, что это не сработает. Чтобы сделать функцию-член другом, компилятор должен увидеть полное определение класса функции-члена (а не только предварительное объявление). Поскольку класс Storage
еще не видел полного определения класса Display
, компилятор выдаст ошибку в тот момент, когда мы попытаемся сделать функцию-член другом.
К счастью, это легко решить, просто поставив определение класса Display
перед определением класса Storage
.
class Display
{
private:
bool m_displayIntFirst;
public:
Display(bool displayIntFirst) { m_displayIntFirst = displayIntFirst; }
// ошибка: компилятор не знает, что такое Storage
void displayItem(const Storage &storage)
{
if (m_displayIntFirst)
std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
else // display double first
std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
}
};
class Storage
{
private:
int m_nValue;
double m_dValue;
public:
Storage(int nValue, double dValue)
{
m_nValue = nValue;
m_dValue = dValue;
}
// Сделаем функцию-член Display::displayItem другом класса Storage
friend void Display::displayItem(const Storage& storage); // теперь нормально
};
Однако теперь у нас есть другая проблема. Поскольку функция-член Display::displayItem()
использует Storage
в качестве ссылочного параметра, а мы только что переместили определение Storage
ниже определения Display
, компилятор будет жаловаться, что не знает, что такое Storage
. Мы не можем исправить это, изменив порядок определения, потому что тогда мы отменим предыдущее исправление.
К счастью, это тоже можно исправить, выполнив пару простых шагов. Во-первых, мы можем добавить класс Storage
в качестве предварительного объявления. Во-вторых, мы можем переместить определение Display::displayItem()
из класса на место после полного определения класса Storage
.
Вот как это выглядит:
#include <iostream>
// предварительное объявление для класса Storage
class Storage;
class Display
{
private:
bool m_displayIntFirst;
public:
Display(bool displayIntFirst) { m_displayIntFirst = displayIntFirst; }
// предварительное объявление, указанное выше, необходимо для этой строки объявления
void displayItem(const Storage &storage);
};
class Storage // полное определение класса Storage
{
private:
int m_nValue;
double m_dValue;
public:
Storage(int nValue, double dValue)
{
m_nValue = nValue;
m_dValue = dValue;
}
// Сделать функцию-член Display::displayItem другом класса Storage
// (требуется увидеть полное объявление класса Display, как указано выше)
friend void Display::displayItem(const Storage& storage);
};
// Теперь мы можем определить Display::displayItem, которой
// необходимо увидеть полное определение класса Storage
void Display::displayItem(const Storage &storage)
{
if (m_displayIntFirst)
std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
else // display double first
std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
}
int main()
{
Storage storage(5, 6.7);
Display display(false);
display.displayItem(storage);
return 0;
}
Теперь всё будет скомпилировано правильно: предварительного объявления класса Storage
достаточно, чтобы удовлетворить объявление Display::displayItem()
, полное определение Display
удовлетворяет объявлению Display::displayItem()
как друга Storage
, а полное определение класса Storage
достаточно, чтобы удовлетворить определение функции-члена Display::displayItem()
. Если это немного сбивает с толку, смотрите комментарии в программе.
Если это похоже на боль – это так. К счастью, этот танец с бубном необходим только потому, что мы пытаемся сделать всё в одном файле. Лучшее решение – поместить определение каждого класса в отдельный заголовочный файл, а определения функций-членов в соответствующие файлы .cpp. Таким образом, все определения классов сразу же были бы видны в файлах .cpp, и не нужно было бы переупорядочивать классы и функции!
Резюме
Дружественная функция или класс – это функция или класс, которые могут получить доступ к закрытым членам другого класса, как если бы они были членами этого класса. Это позволяет дружественной функции или классу тесно работать с другим классом, не заставляя другой класс открывать свои закрытые члены (например, через функции доступа).
Объявление друзьями обычно используется при определении перегруженных операторов (о которых мы поговорим в следующей главе) или, реже, когда два или более класса должны тесно взаимодействовать друг с другом.
Обратите внимание, что для того, чтобы сделать конкретную функцию-член другом, необходимо сначала увидеть полное определение класса функции-члена.
Небольшой тест
Вопрос 1
В геометрии точка – это позиция в пространстве. Мы можем определить точку в трехмерном пространстве как набор координат x, y и z. Например, Point(2.0, 1.0, 0.0)
будет точкой в пространстве с координатами x = 2.0, y = 1.0 и z = 0.0.
В физике вектор – это величина, которая имеет величину (длину) и направление (но не положение). Мы можем определить вектор в трехмерном пространстве как значения x, y и z, представляющие направление вектора вдоль осей x, y и z (длина может быть получена из них). Например, Vector(2.0, 0.0, 0.0)
будет вектором, представляющим направление вдоль (только) положительной оси x, с длиной 2.0.
Вектор можно применить к точке, чтобы переместить точку в новое положение. Это делается путем добавления направления вектора к положению точки, чтобы получить новое положение. Например, Point(2.0, 1.0, 0.0) + Vector(2.0, 0.0, 0.0)
даст точку (4.0, 1.0, 0.0).
Точки и векторы часто используются в компьютерной графике (точки представляют вершины фигуры, а векторы представляют движение фигуры).
Учитывая следующую программу:
#include <iostream>
class Vector3d
{
private:
double m_x{};
double m_y{};
double m_z{};
public:
Vector3d(double x = 0.0, double y = 0.0, double z = 0.0)
: m_x{x}, m_y{y}, m_z{z}
{
}
void print() const
{
std::cout << "Vector(" << m_x << " , " << m_y << " , " << m_z << ")\n";
}
};
class Point3d
{
private:
double m_x{};
double m_y{};
double m_z{};
public:
Point3d(double x = 0.0, double y = 0.0, double z = 0.0)
: m_x{x}, m_y{y}, m_z{z}
{
}
void print() const
{
std::cout << "Point(" << m_x << " , " << m_y << " , " << m_z << ")\n";
}
void moveByVector(const Vector3d &v)
{
// реализуйте эту функцию как друг класса Vector3d
}
};
int main()
{
Point3d p{1.0, 2.0, 3.0};
Vector3d v{2.0, 2.0, -3.0};
p.print();
p.moveByVector(v);
p.print();
return 0;
}
1a) Сделайте Point3d
дружественным классом класса Vector3d
и реализуйте функцию Point3d::moveByVector()
.
Ответ
#include <iostream> class Vector3d { private: double m_x{}; double m_y{}; double m_z{}; public: Vector3d(double x = 0.0, double y = 0.0, double z = 0.0) : m_x{x}, m_y{y}, m_z{z} { } void print() const { std::cout << "Vector(" << m_x << " , " << m_y << " , " << m_z << ")\n"; } friend class Point3d; // Point3d теперь друг класса Vector3d }; class Point3d { private: double m_x{}; double m_y{}; double m_z{}; public: Point3d(double x = 0.0, double y = 0.0, double z = 0.0) : m_x{x}, m_y{y}, m_z{z} { } void print() const { std::cout << "Point(" << m_x << " , " << m_y << " , " << m_z << ")\n"; } void moveByVector(const Vector3d &v) { m_x += v.m_x; m_y += v.m_y; m_z += v.m_z; } }; int main() { Point3d p{1.0, 2.0, 3.0}; Vector3d v{2.0, 2.0, -3.0}; p.print(); p.moveByVector(v); p.print(); return 0; }
1b) Вместо того, чтобы делать класс Point3d
другом класса Vector3d
, сделайте функцию-член Point3d::moveByVector
другом класса Vector3d
.
Ответ
// сначала нам нужно сообщить компилятору, что существует класс с именем Vector3d class Vector3d; class Point3d { private: double m_x{}; double m_y{}; double m_z{}; public: Point3d(double x = 0.0, double y = 0.0, double z = 0.0) : m_x{x}, m_y{y}, m_z{z} { } void print() const { std::cout << "Point(" << m_x << " , " << m_y << " , " << m_z << ")\n"; } void moveByVector(const Vector3d &v); // поэтому мы можем использовать здесь Vector3d // обратите внимание: мы не можем определить эту функцию здесь, потому что Vector3d // еще не объявлен (а просто предварительно объявлен) }; class Vector3d { private: double m_x{}; double m_y{}; double m_z{}; public: Vector3d(double x = 0.0, double y = 0.0, double z = 0.0) : m_x{x}, m_y{y}, m_z{z} { } void print() const { std::cout << "Vector(" << m_x << " , " << m_y << " , " << m_z << ")\n"; } // Point3d::moveByVector() теперь является другом класса Vector3d friend void Point3d::moveByVector(const Vector3d &v); }; // Теперь, когда Vector3d объявлен, мы можем определить функцию Point3d::moveByVector() void Point3d::moveByVector(const Vector3d &v) { m_x += v.m_x; m_y += v.m_y; m_z += v.m_z; } int main() { Point3d p{1.0, 2.0, 3.0}; Vector3d v{2.0, 2.0, -3.0}; p.print(); p.moveByVector(v); p.print(); return 0; }
1c) Реализуйте решение вопроса 1b, используя 5 отдельных файлов: Point3d.h, Point3d.cpp, Vector3d.h, Vector3d.cpp и main.cpp.
Ответ
Point3d.h:
// Заголовочный файл, определяющий класс Point3d #ifndef POINT3D_H #define POINT3D_H // предварительное объявление для класса Vector3d для функции moveByVector() class Vector3d; class Point3d { private: double m_x{}; double m_y{}; double m_z{}; public: Point3d(double x = 0.0, double y = 0.0, double z = 0.0); void print() const; // указанное выше предварительное объявление необходимо для этой строки void moveByVector(const Vector3d &v); }; #endif
Point3d.cpp:
// Функции-члены класса Point3d определены здесь #include "Point3d.h" // класс Point3d объявлен там #include "Vector3d.h" // для параметра функции moveByVector() #include <iostream> // для std::cout Point3d::Point3d(double x, double y, double z) : m_x{x}, m_y{y}, m_z{z} {} void Point3d::moveByVector(const Vector3d &v) { // Добавляем компоненты вектора к соответствующим координатам точки m_x += v.m_x; m_y += v.m_y; m_z += v.m_z; } void Point3d::print() const { std::cout << "Point(" << m_x << " , " << m_y << " , " << m_z << ")\n"; }
Vector3d.h:
// Заголовочный файл, определяющий класс Vector3d #ifndef VECTOR3D_H #define VECTOR3D_H #include "Point3d.h" // для объявления Point3d::moveByVector() другом class Vector3d { private: double m_x{}; double m_y{}; double m_z{}; public: Vector3d(double x = 0.0, double y = 0.0, double z = 0.0); void print() const; friend void Point3d::moveByVector(const Vector3d &v); }; #endif
Vector3d.cpp:
// Функции-члены класса Vector3d определены здесь #include "Vector3d.h" // класс Vector3d объявлен в этом файле #include <iostream> Vector3d::Vector3d(double x, double y, double z) : m_x{x}, m_y{y}, m_z{z} {} void Vector3d::print() const { std::cout << "Vector(" << m_x << " , " << m_y << " , " << m_z << ")\n"; }
main.cpp:
#include "Vector3d.h" // для создания объекта Vector3d #include "Point3d.h" // для создания объекта Point3d int main() { Point3d p{1.0, 2.0, 3.0}; Vector3d v{2.0, 2.0, -3.0}; p.print(); p.moveByVector(v); p.print(); return 0; }