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



这个问题的主要部分是关于为类内的私有数据成员创建公共只读访问器的正确且计算效率最高的方法。具体而言,利用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/ssetters"的必要性(或缺乏必要性)已经在一些问题上反复讨论过,例如:C++中访问器方法(getters和setters)的约定,但这并不是眼前的问题。

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

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.

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

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

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

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

  1. 这两种方法有何不同?是什么原因导致其中一种方法比其他方法更快/更慢
  2. 为什么既定的做法是使用常量"getter"函数,而使用常量引用却鲜为人知,更不用说使用了

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

问题2的答案是,有时,您可能想要更改类内部。如果你公开了所有属性,它们就是接口的一部分,所以即使你想出了一个不需要它们的更好的实现(比如说,它可以快速重新计算值,并减少每个实例的大小,这样生成1亿个属性的程序现在使用的内存就减少了400-800 MB),你也无法在不破坏依赖代码的情况下删除它。

启用优化后,当getter的代码只是直接成员访问时,getter函数应该与直接成员访问不可区分。但是,如果您想要更改值的派生方式以移除成员变量并即时计算值,则可以在不更改公共接口的情况下更改getter实现(重新编译将使用API修复现有代码,而不更改代码),因为函数不受变量的限制

存在语义/行为差异,这些差异远比您的(坏的)基准更显著。

复制语义被破坏

一个活生生的例子:

#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。这可能会导致挂起引用(并导致崩溃)。

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

分配已中断

引用不能重新密封(您可以更改其裁判的内容,但不能将其切换到另一个裁判),导致:

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返回,您可以将内部表示切换到另一种类型,甚至可以消除成员并动态计算它。

只有当接口暴露给期望二进制/源代码级别兼容性的客户端时,这才是重要的;如果该类只在内部使用,并且如果它发生更改,您可以更改所有用户,那么这就不是问题。


既然语义已经过时了,我们就可以讨论性能差异了。

对象大小增加

虽然引用有时可以被忽略,但在这里不太可能发生。这意味着每个引用成员将使对象的大小至少增加sizeof(void*),并可能增加一些用于对齐的填充。

在具有主流编译器的x86或x86-64平台上,原始类MyClassA的大小为4

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

增加的大小可能会破坏CPU缓存,大量的项目可能会因此而很快减慢速度(好吧,并不是说Broken的向量会因为其赋值运算符损坏而变得容易)。

更好的调试性能

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

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


最后,使用getter。它的既定惯例有很多原因:)

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

顺便说一下,getter方法也可以是虚拟的。

回答问题2:

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

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

最新更新