重载运算符 [] 从 1 开始,并产生性能开销

Overloading operator[] to start at 1 and performance overhead

本文关键字:性能 开销 开始 运算符 重载      更新时间:2023-10-16

我正在做一些C++计算力学(别担心,这里不需要物理知识),有一些事情真的困扰着我。

假设我想表示一个 3D 数学向量(与 std::vector 无关):

class Vector {
    public:
        Vector(double x=0., double y=0., double z=0.) { 
            coordinates[0] = x;
            coordinates[1] = y;
            coordinates[2] = z;
        }
    private:
        double coordinates[3];
};

目前为止,一切都好。现在我可以重载运算符 [] 来提取坐标:

double& Vector::operator[](int i) {
     return coordinates[i] ;
}

所以我可以输入:

Vector V; 
… //complex computation with V
double x1 = V[0];
V[1] = coord2;

问题是,从 0 开始索引在这里并不自然。我的意思是,在对数组进行排序时,我不介意,但事实是,每篇论文、书籍或任何事物中的常规符号总是从 1 开始细分坐标。这似乎是一个狡辩,但事实是,在公式中,总是需要双重理解我们正在做什么。当然,对于矩阵来说,这是最糟糕的。

一个明显的解决方案只是略有不同的重载:

double& Vector::operator[](int i) {
     return coordinates[i-1] ;
}

所以我可以打字

double x1 = V[1];
V[2] = coord2;

它看起来很完美,除了一件事:这个 i-1 减法似乎是小开销的良好候选者。你会说很小,但我正在做计算力学,所以这通常是我们负担不起的。

所以现在(最后)我的问题:你认为编译器可以优化它,还是有办法让它优化?(模板、宏、指针或引用工具...

从逻辑上讲,在

double xi = V[i];

括号之间的整数大部分时间都是文字(循环的 3 次迭代除外),内联运算符 [] 应该成为可能,对吧?

(对不起,这个龙的问题)

编辑:

感谢您的所有评论和回答

我有点不同意人们告诉我我们已经习惯了 0 索引向量。从面向对象的角度来看,我认为没有理由将数学向量设置为 0 索引,因为使用 0 索引数组实现。我们不应该关心底层实现。现在,假设我不关心性能并使用映射来实现 Vector 类。然后我会发现将"1"与"1st"坐标映射是很自然的。

也就是说,我尝试了使用 1 索引的向量和矩阵,经过一些代码编写,我发现每次使用数组时它都不能很好地交互。我选择向量和容器(std::array,std::vector...)不会经常交互(意思是,在彼此之间传输数据),但似乎我错了。

现在我有一个我认为争议较小的解决方案(请给我你的意见):每次我在某个物理环境中使用 Vector 时,我都会想到使用枚举:

enum Coord {
    x = 0,
    y = 1,
    z = 2
};
Vector V;
V[x] = 1;

我看到的唯一缺点是这些 x,y 和 z 可以在没有警告的情况下重新定义......

这个应该通过查看反汇编来测量或验证,但我的猜测是:getter 函数很小,它的参数是恒定的。编译器很有可能会内联函数并不断折叠减法。在这种情况下,运行时成本将为零。

为什么不试试这个:

class Vector {
    public:
        Vector(double x=0., double y=0., double z=0.) { 
            coordinates[1] = x;
            coordinates[2] = y;
            coordinates[3] = z;
        }
    private:
        double coordinates[4];
};

如果您没有实例化数百万个对象,那么内存腰部可能是负担得起的。

您是否实际分析过它或检查了生成的代码?这就是回答这个问题的方式。

如果 operator[] 实现可见,则可能会对其进行优化以使其开销为零。

我建议你在类的标题(.h)中定义它。 如果在.cpp中定义它,则编译器无法进行太多优化。 此外,您的索引不应该是可以具有负值的"int"...让它成为size_t:

class Vector {
    // ...
public:
    double& operator[](const size_t i) {
        return coordinates[i-1] ;
    }
};

如果没有基准测试,你就无法对性能说任何客观的话。在 x86 上,这个减法可以使用相对寻址来编译,这非常便宜。如果operator[]是内联的,则开销为零 - 您可以使用inline或特定于编译器的指令(如 GCC 的 __attribute__((always_inline)))来鼓励这样做。

如果必须保证这一点,并且偏移量是编译时常量,那么使用模板是可行的方法:

template<size_t I>
double& Vector::get() {
    return coordinates[i - 1];
}
double x = v.get<1>();

出于所有实际目的,由于不断折叠,这保证了零开销。您还可以使用命名访问器:

double Vector::x() const { return coordinates[0]; }
double Vector::y() const { return coordinates[1]; }
double Vector::z() const { return coordinates[2]; }
double& Vector::x() { return coordinates[0]; }
double& Vector::y() { return coordinates[1]; }
double& Vector::z() { return coordinates[2]; }

对于循环,迭代器:

const double* Vector::begin() const { return coordinates; }
const double* Vector::end() const { return coordinates + 3; }
double* Vector::begin() { return coordinates; }
double* Vector::end() { return coordinates + 3; }
// (x, y, z) -> (x + 1, y + 1, z + 1)
for (auto& i : v) ++i;

然而,像这里的许多其他人一样,我不同意你问题的前提。您确实应该简单地使用基于 0 的索引,因为它在C++领域更自然。该语言已经非常复杂,对于那些将来将维护您的代码的人来说,您不需要使事情进一步复杂化。

说真的,对所有三种方法进行基准测试(即,将减法和双精度[4]方法与在调用者中使用从零开始的索引进行比较)。

在某些

缓存架构上强制 16 字节对齐,您完全有可能获得巨大的胜利,同样有可能在某些编译器/指令集/代码路径组合上减法实际上是免费的。

唯一的判断方法是对实际代码进行基准测试。