15-8 友元非成员函数

在本章及上一章的大部分内容中,我们一直在强调访问控制的优点——它提供了一种机制来控制谁能访问类的各个成员。私有成员只能被该类的其他成员访问,而公有成员则可被所有人访问。在第14.6节《访问函数》中,我们讨论了保持数据私有化并为非成员创建公共接口的好处。

然而在某些场景下,这种设计要么不够完善,要么不够理想。

例如,假设存在一个专注于管理数据集的存储类。现在需要同时实现数据展示功能,但展示逻辑涉及大量选项且较为复杂。若将存储管理与展示管理功能混置于同一类中,将导致代码混乱且接口复杂。另一种方案是分离职责:存储类负责存储,另设显示类管理所有展示功能。这种职责分离固然理想,但显示类将无法访问存储类的私有成员,可能导致功能缺失。

此外,某些语法场景下我们更倾向于使用非成员函数而非成员函数(下文将举例说明)。这种情况常见于运算符重载(后续课程将讨论),但非成员函数同样面临无法访问类私有成员的困境。

若现有访问函数(或其他公成员函数)已能满足需求,则直接使用即可。但某些情况下这些函数并不存在。该怎么办?

一种方案是向类添加新成员函数,让其他类或非成员函数完成原本无法实现的任务。但我们可能不希望公开访问这些功能——它们可能高度依赖具体实现,或容易被误用。

我们真正需要的是某种机制,能在特定场景下绕过访问控制系统。

具有魔力的友元关系Friendship is magic

我们挑战的答案就是友元关系。

在类体内,可通过友元声明friend declaration(使用friend关键字)告知编译器某个其他类或函数已成为朋友。在C++中,友元friend是指被授予完全访问权限的类或函数(成员或非成员),可访问另一个类的私有和受保护成员。通过这种方式,类可有选择地授予其他类或函数对其成员的完全访问权限,而不会影响其他任何内容。

核心要点

友元关系始终由将被访问的成员所属类授予(而非请求访问的类或函数)。在访问控制与授予友谊权限之间,类始终保留着控制其成员访问权限的能力。

例如,若存储类将显示类设为友元,则显示类可直接访问存储类的全部成员。显示类可利用此直接访问权限实现存储类的显示功能,同时保持结构上的独立性。

友元声明不受访问控制影响,因此在类主体中的位置无关紧要。

了解友元概念后,我们将通过具体示例探讨向非成员函数、成员函数及其他类授予友元权限的情况。本节将讨论友元非成员函数,后续第15.9节——友元类与友元成员函数中将深入解析友元类与友元成员函数。


友元非成员函数

友元函数是指能够访问类私有和受保护成员的函数(成员函数或非成员函数),其访问权限如同该函数是该类的成员函数。在其他所有方面,友元函数都是普通的函数。

下面通过一个简单类将非成员函数设为友元的示例来说明:

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 };

public:
    void add(int value) { m_value += value; }

    // Here is the friend declaration that makes non-member function void print(const Accumulator& accumulator) a friend of Accumulator
    friend void print(const Accumulator& accumulator);
};

void print(const Accumulator& accumulator)
{
    // Because print() is a friend of Accumulator
    // it can access the private members of Accumulator
    std::cout << accumulator.m_value;
}

int main()
{
    Accumulator acc{};
    acc.add(5); // add 5 to the accumulator

    print(acc); // call the print() non-member function

    return 0;
}

image

在此示例中,我们声明了一个名为 print() 的非成员函数,该函数接受 Accumulator 类的对象。由于 print() 并非 Accumulator 类的成员,通常无法访问私有成员 m_value。但 Accumulator 类通过友元声明将 print(const Accumulator& accumulator) 设为友元函数,因此现在允许访问。

请注意,由于 print() 是非成员函数(因此没有隐式对象),我们必须显式地向 print() 传递一个 Accumulator 对象才能进行操作。


在类内部定义非成员友元函数

正如成员函数可根据需要在类内部定义,非成员友元函数同样可以在类内部定义。以下示例在Accumulator类内部定义了非成员友元函数print():

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 };

public:
    void add(int value) { m_value += value; }

    // Friend functions defined inside a class are non-member functions
    friend void print(const Accumulator& accumulator)
    {
        // Because print() is a friend of Accumulator
        // it can access the private members of Accumulator
        std::cout << accumulator.m_value;
    }
};

int main()
{
    Accumulator acc{};
    acc.add(5); // add 5 to the accumulator

    print(acc); // call the print() non-member function

    return 0;
}

image

尽管你可能认为由于 print() 在 Accumulator 内部定义,因此它属于 Accumulator 的成员函数,但实际并非如此。由于 print() 被定义为友元函数,它会被视为非成员函数(如同在 Accumulator 外部定义一样)。

#include <iostream>

class Value
{
private:
    int m_value{};

public:
    explicit Value(int v): m_value { v }  { }

    bool isEqualToMember(const Value& v) const;
    friend bool isEqualToNonmember(const Value& v1, const Value& v2);
};

bool Value::isEqualToMember(const Value& v) const
{
    return m_value == v.m_value;
}

bool isEqualToNonmember(const Value& v1, const Value& v2)
{
    return v1.m_value == v2.m_value;
}

int main()
{
    Value v1 { 5 };
    Value v2 { 6 };

    std::cout << v1.isEqualToMember(v2) << '\n';
    std::cout << isEqualToNonmember(v1, v2) << '\n';

    return 0;
}

image

义了两个用于检查两个Value对象是否相等的相似函数。isEqualToMember()是成员函数,isEqualToNonmember()是非成员函数。让我们重点关注这些函数的定义方式。

在 isEqualToMember() 中,我们隐式传递一个对象,显式传递另一个对象。函数实现反映了这种差异,我们需要在思维中协调 m_value 属于隐式对象,而 v.m_value 属于显式参数的关系。

在 isEqualToNonmember() 中,两个对象均通过显式方式传递。这种设计使函数实现更具并行性,因为 m_value 成员始终被显式参数明确标记。

您可能仍更倾向于使用 v1.isEqualToMember(v2) 这种调用语法而非 isEqualToNonmember(v1, v2)。但当我们探讨运算符重载时,这个话题将再次出现。


多个友元Multiple friends

一个函数可以同时成为多个类的友元函数。例如,请看以下示例:

#include <iostream>

class Humidity; // forward declaration of Humidity

class Temperature
{
private:
    int m_temp { 0 };
public:
    explicit Temperature(int temp) : m_temp { temp } { }

    friend void printWeather(const Temperature& temperature, const Humidity& humidity); // forward declaration needed for this line
};

class Humidity
{
private:
    int m_humidity { 0 };
public:
    explicit Humidity(int humidity) : 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;
}

image

此示例有三个值得注意的要点。首先,由于printWeather()函数同时平等地使用Humidity和Temperature类,将其设为任一类的成员函数并不合理。采用非成员函数更为合适。其次,由于printWeather()是Humidity和Temperature的共同朋友函数,它能够访问这两个类对象的私有数据。最后,请注意示例开头的以下代码行:

class Humidity;

这是对类 Humidity 的前向声明。类的前向声明与函数的前向声明具有相同的作用——它们告知编译器某个将在后续定义的标识符。然而,与函数不同的是,类没有返回类型或参数,因此类的前向声明始终仅为 class ClassName(除非它们是类模板)。

若缺少此行声明,编译器在解析Temperature内部的朋友声明时,将报错提示无法识别Humidity类型。


友元关系是否违反了数据隐藏原则?

不。数据隐藏的实现类授予友元关系访问权限时,就预期该朋友会访问其私有成员。可将友元视为类本身的延伸,拥有完全相同的访问权限。因此这种访问是预期的,而非违反原则。

合理运用友元关系可提升程序可维护性:当设计逻辑允许时,能将功能模块分离(而非因访问控制需求强行绑定);或在更适合使用非成员函数而非成员函数时提供灵活性。

但需注意:由于友元可直接访问类实现细节,当类实现变更时通常也需同步修改友元函数。若类拥有众多友元函数(或这些友元函数又拥有自己的友元函数),则可能引发连锁反应。

实现友元函数时,应尽可能优先使用公共接口而非直接访问成员。此举有助于隔离友元函数免受未来实现变更的影响,减少后续需要修改和/或重新测试的代码量。

最佳实践
友元函数应尽可能优先使用类接口而非直接访问。


优先使用非友元函数而非友元函数

第14.8课——数据隐藏(封装)的优势中,我们提到应优先使用非成员函数而非成员函数。基于相同的理由,我们也应优先使用非友元函数而非友元函数。

例如,在下面的示例中,如果修改Accumulator的实现(例如重命名m_value),则print()的实现也需要相应修改:

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 }; // if we rename this

public:
    void add(int value) { m_value += value; } // we need to modify this

    friend void print(const Accumulator& accumulator);
};

void print(const Accumulator& accumulator)
{
    std::cout << accumulator.m_value; // and we need to modify this
}

int main()
{
    Accumulator acc{};
    acc.add(5); // add 5 to the accumulator

    print(acc); // call the print() non-member function

    return 0;
}

image

更好的想法如下:

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 };

public:
    void add(int value) { m_value += value; }
    int value() const { return m_value; } // added this reasonable access function
};

void print(const Accumulator& accumulator) // no longer a friend of Accumulator
{
    std::cout << accumulator.value(); // use access function instead of direct access
}

int main()
{
    Accumulator acc{};
    acc.add(5); // add 5 to the accumulator

    print(acc); // call the print() non-member function

    return 0;
}

image

在此示例中,print() 使用访问函数 value() 获取 m_value 的值,而非直接访问 m_value。这样即使 Accumulator 的实现发生变更,print() 也无需更新。

最佳实践:
在可行且合理的情况下,优先将函数实现为非友元函数。

向现有类的公共接口添加新成员时需谨慎,因为每个函数(即使是简单的函数)都会增加一定程度的冗余和复杂性。以上述Accumulator为例,使用访问函数获取当前累积值是完全合理的。在更复杂的情况下,与在类接口中添加大量新访问函数相比,使用友元关系可能是更优的选择。

posted @ 2026-01-03 00:24  游翔  阅读(9)  评论(0)    收藏  举报