私有成员的常量访问者之间的比较

Comparison between constant accessors of private members

这个问题的主要部分是关于为 class 内部的私有数据成员创建 public 只读访问器的正确且计算效率最高的方法。具体来说,利用 const type & 引用访问变量,例如:

class MyClassReference
{
private:
    int myPrivateInteger;

public:
    const int & myIntegerAccessor;

    // Assign myPrivateInteger to the constant accessor.
    MyClassReference() : myIntegerAccessor(myPrivateInteger) {}
};

然而,目前解决这个问题的既定方法是利用常量 "getter" 函数,如下所示:

class MyClassGetter
{
private:
    int myPrivateInteger;

public:
    int getMyInteger() const { return myPrivateInteger; }
};

"getters/setters" 的必要性(或缺乏必要性)已经在诸如以下的问题上反复讨论过:Conventions for accessor methods (getters and setters) in C++ 然而,这不是当前的问题。

这两种方法使用语法提供相同的功能:

MyClassGetter a;
MyClassReference b;    
int SomeValue = 5;

int A_i = a.getMyInteger(); // Allowed.    
a.getMyInteger() = SomeValue; // Not allowed.

int B_i = b.myIntegerAccessor; // Allowed.    
b.myIntegerAccessor = SomeValue; // Not allowed.

发现这一点后,在互联网上找不到任何相关信息,我问了我的几位导师和教授,哪个合适,每个人的亲戚是什么advantages/disadvantages。但是,我收到的所有回复都很好地分为两类:

  1. 我从来没有想过,但是使用 "getter" 方法,因为它是 "Established Practice"。
  2. 它们的功能相同(它们 运行 具有相同的效率),但使用 "getter" 方法,因为它是 "Established Practice".

虽然这两个答案都是合理的,但由于它们都未能解释 "why" 我感到不满意并决定进一步调查此问题。虽然我进行了多项测试,例如平均字符使用(它们大致相同)、平均打字时间(同样大致相同),但一项测试显示这两种方法之间存在极大差异。这是调用访问器并将其分配给整数的 运行 时间测试。没有任何 -OX 标志(在调试模式下),MyClassReference 的执行速度大约快 15%。但是,一旦添加了 -OX 标志,除了执行速度更快之外,这两种方法 运行 具有相同的效率。

因此我的问题分为两部分。

  1. 这两种方法有何不同,是什么导致一种方法 faster/slower 比其他方法仅具有某些优化标志?
  2. 为什么惯例是使用常量 "getter" 函数,而使用常量引用却鲜为人知,更不用说使用了?

正如评论所指出的,我的基准测试存在缺陷,与手头的事情无关。但是,对于上下文,它可以位于修订历史记录中。

回答问题 2:

const_cast<int&>(mcb.myIntegerAccessor) = 4;

是将它隐藏在 getter 函数后面的一个很好的理由。这是一种执行类似 getter 操作的聪明方法,但它完全破坏了 class.

中的抽象

问题 2 的答案是,有时您可能想要更改 class 内部结构。如果您创建了所有属性 public,它们就是接口的一部分,所以即使您想出一个不需要它们的更好的实现(例如,它可以快速重新计算值并剃须每个实例的大小,因此产生 1 亿个实例的程序现在使用的内存减少了 400-800 MB),您不能在不破坏相关代码的情况下删除它。

启用优化后,当 getter 的代码只是直接成员访问时,getter 函数应该与直接成员访问没有区别。但是如果你想更改值的派生方式以删除成员变量并动态计算值,你可以更改 getter 实现而不更改 public 接口(重新编译将修复使用 API 的现有代码,最后没有代码更改),因为函数不受变量限制。

当实现常量引用(或常量指针)时,您的对象还存储了一个指针,这使得它的大小更大。另一方面,访问器方法仅在程序中实例化一次并且很可能被优化掉(内联),除非它们是虚拟的或导出接口的一部分。

对了,getter方法也可以虚

semantic/behavioral 比您的(损坏的)基准测试更显着的差异。

复制语义被破坏

一个live example:

#include <iostream>

class Broken {
public:
    Broken(int i): read_only(read_write), read_write(i) {}

    int const& read_only;

    void set(int i) { read_write = i; }

private:
    int read_write;
};

int main() {
    Broken original(5);
    Broken copy(original);

    std::cout << copy.read_only << "\n";

    original.set(42);

    std::cout << copy.read_only << "\n";
    return 0;
}

产量:

5
42

问题是在复制的时候,copy.read_only指向original.read_write。这可能会导致 悬空引用 (和崩溃)。

这可以通过编写自己的复制构造函数来解决,但这很痛苦。

分配被破坏

一个引用不能被重新定位(你可以改变它的引用的内容,但不能切换到另一个引用),leading to:

int main() {
    Broken original(5);
    Broken copy(4);
    copy = original;

    std::cout << copy.read_only << "\n";

    original.set(42);

    std::cout << copy.read_only << "\n";
    return 0;
}

生成错误:

prog.cpp: In function 'int main()':
prog.cpp:18:7: error: use of deleted function 'Broken& Broken::operator=(const Broken&)'
  copy = original;
       ^
prog.cpp:3:7: note: 'Broken& Broken::operator=(const Broken&)' is implicitly deleted because the default definition would be ill-formed:
 class Broken {
       ^
prog.cpp:3:7: error: non-static reference member 'const int& Broken::read_only', can't use default assignment operator

这可以通过编写自己的复制构造函数来解决,但这很痛苦。

除非您修复它,否则 Broken 只能以非常有限的方式使用;例如,您可能永远无法将它放在 std::vector 中。

增加耦合

放弃对内部结构的引用会增加耦合。您泄露了一个实现细节(您使用的是 int 而不是 shortlonglong long)。

使用 getter 返回 value,您可以将内部表示切换为另一种类型,甚至可以删除成员并即时计算它。

只有当接口暴露给期望 binary/source-level 兼容性的客户端时,这才有意义;如果 class 仅在内部使用,并且您可以负担得起更改所有用户的费用,那么这不是问题。


既然语义已经不在话下,我们可以谈谈性能差异了。

增加对象大小

虽然引用有时会被省略,但这种情况永远不会发生。这意味着每个引用成员都会将对象的大小增加至少 sizeof(void*),加上可能的一些对齐填充。

原来的classMyClassA在x86或x86-64平台主流编译器上大小为4

Broken class 在 x86 平台上的大小为 8,在 x86-64 平台上的大小为 16(后者是因为填充,因为指针在8 字节边界)。

增加的大小会破坏 CPU 缓存,如果有大量项目,您可能会很快遇到速度变慢的情况(好吧,并不是说 [=16 的向量会很容易) =] 由于其损坏的赋值运算符)。

更好的调试性能

只要 getter 的实现在 class 定义中是内联的,那么只要您使用足够的优化级别进行编译,编译器就会去除 getter (-O2-O3 通常,-O1 可能无法启用内联以保留堆栈跟踪)。

因此,访问的性能应该只在调试代码中有所不同,其中性能是最不需要的(否则会受到许多其他因素的影响,因此无关紧要)。


最后,用一个getter。出于多种原因,它已成为惯例 :)