C++ 中的隐藏好友概念

Hidden Friend Concept in C++

提问人:John James 提问时间:7/23/2023 最后编辑:John James 更新时间:7/29/2023 访问量:251

问:

我仍然是 C++ 的初学者,试图更多地了解这门语言。我最近读到了 ADL(参数相关查找)和隐藏的朋友成语(https://www.modernescpp.com/index.php/argument-dependent-lookup-and-hidden-friends)的概念。我对 ADL 的理解是,在非限定函数调用的情况下,C++ 不仅在当前命名空间中查找函数,而且在参数类型的命名空间中查找函数。

我对隐藏的朋友成语的意义是什么感到困惑,以及隐藏的朋友到底是什么意思(即隐藏了什么)。我知道类的友元函数是非成员函数,但可以访问类的私有成员。但是,我不明白为什么它们是必要的。在阅读中给出的代码示例中,它指出了给定函数中朋友的必要性,特别是对于具有自定义类的两个参数的一般重载。也就是说,在

class MyDistance{
  public:
    explicit MyDistance(double i):m(i){}

    MyDistance operator +(const MyDistance& a, const MyDistance& b){
        return MyDistance(a.m + b.m);
    }
    
    friend MyDistance operator -(const MyDistance& a, const MyDistance& b){
        return MyDistance(a.m - b.m);
    }
    
    friend std::ostream& operator<< (std::ostream &out, const MyDistance& myDist){
        out << myDist.m << " m";
        return out;
    }

  private:
    double m;

};

该类的 + 运算符重载不是朋友,是一个成员函数,从技术上讲,我相信这里采用了 3 个参数,因为它是一个成员函数 (this) 并接受 2 个额外的参数,使其无效。MyDistance

但是,我们不能把代码写成

class MyDistance{
  public:
    ...
    
    MyDistance operator +(const MyDistance& other){
        return MyDistance(m + other.m);
    }
    ...
};

像这样写代码有什么缺点吗?由于 C++ 执行查找的顺序(也许在查看成员函数之前查看非成员函数),它在某种程度上(在编译时)变慢了吗?另外,“隐藏的朋友成语”到底应该“隐藏”什么?是函数本身是在类中定义的,而不是在类之外定义的吗?

C++ 朋友 习语

评论

0赞 ALX23z 7/23/2023
对于成员运营商来说,没有真正的缺点。这只是语法。在某些情况下,编写一个自由函数会更好,但在这种情况下它是无关紧要的。对于第一个参数不是此类实例的运算符,该运算符是必需的。就像你在这里的操作员一样。friend<<
0赞 john 7/23/2023
这对我来说是一个新概念,但这似乎是一篇关于隐藏朋友的更好文章,尽管即使在这里,我认为有些代码也有点古怪。
1赞 BoP 7/23/2023
隐藏朋友的“隐藏”部分是它只能被 ADL 找到。这将它的使用限制在实际具有类类型的对象的情况下,但不包括仅可转换为该类型的类型的使用。有时这就是你想要的。(这是在路径运算符中发现的<<其中可以通过临时对象将宽字符串转换为窄字符串。哎呀!path
0赞 John James 7/24/2023
@BoP 为什么隐藏的朋友只能通过 ADL 找到,这到底是什么意思?我确实看到了 ADL 如何能够找到它,因为它查看了参数类型的命名空间,其中包括朋友函数。但是,当我们让 + 运算符重载成员函数时,情况不是这样吗?如果 + 函数不是隐藏的朋友,还有没有另一种调用它的方法?
1赞 BoP 7/24/2023
“仅由 ADL 找到”意味着“隐藏的好友”只有在我们已经有一个类类型的对象时才可见。因此,看看班级内部。否则,编译器可以先找到一个自由函数/运算符,然后才考虑转换为类类型以匹配参数。隐藏的好友在班级外是不可见的,因此在第一阶段永远不会被考虑。

答:

4赞 john 7/23/2023 #1

有什么缺点吗?是的,在上面的示例中,C++ 对运算符 + 的两个参数应用了不同的规则。具体来说,左手参数必须是类型的对象,但右手参数可以是任何可转换为 的类型。MyDistanceMyDistance

稍微扩展一下您的示例

class MyDistance{
  public:
    ...
    MyDistance(int dist) { ... }

    MyDistance operator+(const MyDistance& other) const {
        return MyDistance(m + other.m);
    }
    ...
};

使用此代码

MyDistance x(1);
MyDistance y = x + 2;

是合法的,因为有从 到 的转换,但这是非法的intMyDistance

MyDistance x(1);
MyDistance y = 2 + x;

因为给定左手边的声明必须是一个对象。+MyDistance

当是朋友时没有这样的问题,在这种情况下,任何一个参数都可以转换为,并且上面的两个版本的代码都是合法的。operator+MyDistance

我们的期望是对称的,所以朋友版本更好,因为它对两个参数应用相同的规则。operator+

评论

0赞 ALX23z 7/23/2023
如果有的话,对我来说,它看起来更像是一个骗局,而不是优点。进行这种隐式转换不是一个好主意,并且操作员添加额外的自由度可能会导致有问题和意外的解决方案。
3赞 john 7/23/2023
@ALX23z 这要看情况,但如果你不想要隐式转换,那么首先不要在你的类中编写它们。您可以使用编写二进制运算符的任一方法进行隐式转换,唯一的区别是它们是否对称应用。
0赞 ALX23z 7/23/2023
如果我希望运算符接受类集合,我会编写一个模板来正确声明它接受的内容,而不是依赖于像隐式转换这样善变和不可靠的东西。
0赞 ALX23z 7/23/2023
所以是的。无隐式转换。让操作员成为朋友会增加任何价值吗?
0赞 John James 7/24/2023
我明白你的意思了。您是否也知道 C++ 编译器如何从 a+b 到 a.operator+(b) 再到 operator+(a, b)?C++ 调用其他可能的函数来解析 a+b,C++ 尝试这些函数的顺序是什么?我定义了成员运算符+重载和隐藏的朋友,看起来C++(至少在gcc中)优先考虑成员运算符。这是否会导致任何编译时加速?
2赞 tbxfreeware 7/24/2023 #2

隐藏的朋友就是你的朋友

丹·萨克斯(Dan Saks)在CppCon2018上发表了精彩的演讲,解释了隐藏的朋友。它的标题是结交新朋友

除了@john解释的问题外,模板是掌握“隐藏朋友”成语的另一个重要原因。

流插入和提取运算符,最好用 和 编写,以及 所基于的模板。以这种方式编写,运算符将适用于任何字符类型。operator<<operator>>std::basic_ostreamstd::basic_istreamstd::ostreamstd::istream

当您正在读取和写入的对象本身就是模板时,事情会很快变得复杂。如果流插入和提取运算符函数未隐藏在对象类内部,而是写入对象类外部,则必须对对象和流使用模板参数。当运算符函数被写成隐藏的好友时,在对象类中,你仍然需要提供模板参数,但只针对流(而不是对象)。

例如,假设您决定向类添加一个模板参数。如果不是隐藏的好友,则代码可能如下所示。它驻留在类 MyDistance 之外的作用域中,可以在没有 ADL 的情况下找到。MyDistanceoperator<<operator<<

这是一个完整的程序(它运行):

#include <iostream>
#include <type_traits>

template< typename NumType >
class MyDistance {
    static_assert(std::is_arithmetic_v<NumType>, "");
public:
    explicit MyDistance(NumType i) :m(i) {}

    // ...

    // This is a declaration that says, in essence, "In the 
    // scope outside this class, there is visible a definition 
    // for the templated operator<< declared here, and that 
    // operator function template is my friend." 
    // 
    // Although it is a friend, it is not hidden.
    //
    // operator<< requires three template parameters.
    // Parameter NumType2 is distinct from NumType.
    template< typename charT, typename traits, typename NumType2 >
    friend auto operator<< (
        std::basic_ostream<charT, traits>& out,
        const MyDistance<NumType2>& myDist
        )
        -> std::basic_ostream<charT, traits>&;

private:
    NumType m;
};

// operator<< is not hidden, because it is defined outside
// of class MyDistance, and it is therefore visible in the 
// scope outside class MyDistance. It can be found without ADL.
//
// Here we can use NumType, NumType2, T, or anything else 
// as the third template parameter. It's just a name.
template< typename charT, typename traits, typename NumType >
auto operator<< (
    std::basic_ostream<charT, traits>& out,
    const MyDistance<NumType>& myDist
    )
    -> std::basic_ostream<charT, traits>&
{
    out << myDist.m << " m";
    return out;
}

int main()
{
    MyDistance<int> md_int{ 42 };
    MyDistance<double> md_double{ 3.14 };
    std::cout 
        << "MyDistance<int>    : " << md_int << '\n' 
        << "MyDistance<double> : " << md_double << '\n';
    return 0;
}

当编写为隐藏的朋友时,代码既简洁又简洁。这在类 MyDistance 之外的作用域中不可见,只能在 ADL 中找到。operator<<

这也是一个完整的程序:

#include <iostream>
#include <type_traits>

template< typename NumType >
class MyDistance {
    static_assert(std::is_arithmetic_v<NumType>, "");
public:
    explicit MyDistance(NumType i) :m(i) {}

    // ...

    // operator<< has only the two template parameters 
    // required by std::basic_ostream. It is only visible 
    // within class MyDistance, so it is "hidden." 
    //
    // You cannot scope to it either, using the scope resolution 
    // operator(::), because it is not a member of the class!
    // 
    // It is truly hidden, and can only be found with ADL.
    template< typename charT, typename traits>
    friend auto operator<< (
        std::basic_ostream<charT, traits>& out,
        const MyDistance& myDist
        )
        -> std::basic_ostream<charT, traits>&
    {
        out << myDist.m << " m";
        return out;
    }

private:
    NumType m;
};

int main()
{
    MyDistance<int> md_int{ 42 };
    MyDistance<double> md_double{ 3.14 };
    std::cout
        << "MyDistance<int>    : " << md_int << '\n'
        << "MyDistance<double> : " << md_double << '\n';
    return 0;
}

现在,假设 MyDistance 是一个更复杂的对象,具有许多模板参数,其中一些参数本身可能已模板化。

几年前,我用罗马数字构建了算术课程。我还写了类来做有理数的算术,其中分子和分母是分开存储的。然后我萌生了一个好主意,允许用罗马数字构造有理数!但我也希望类 Rational 继续处理整数。真是一团糟!让流运算符工作非常小心,这样他们就会输出类似:xiii/c 之类的内容。RomanNumeral<IntType>Rational<IntType>

这是一个很好的练习。如果你尝试一下,你会学到的一件事是,隐藏的朋友就是你的朋友!