C++ 踩坑: 返回值优化 - Return Value Optimization, RVO

3,150 阅读4分钟

C++ 踩坑: 返回值优化 (RVO)

返回值优化 (Return Value Optimization, RVO) 是编译器一种 抑制拷贝(Copy Elision) 的优化机制, 避免代码发生不必要的拷贝. 特别对于返回一些局部创建的大对象来说, 有助于提高性能. 虽然这是编译器的行为, 但是并非所有情况下, 编译器都会对返回值进行优化. 因此, 开发者需要搞清楚何种情况, 才会触发此机制.

返回值优化, 包括 RVO 和 NRVO (Named RVO). 一般未具体说明, 前者包含后者. 为方便于观察对象的构造过程, 这里提供了一个基础类 (Rule of Three, 无移动构造函数和移动赋值操作符).

static int counter; // counter to identify instances

struct Data {
    int i{ 0 };
    int id;

    Data() : id{ ++counter } {
        std::cout << "ctor " << id << "\n";
    }

    Data(const Data& s) : i{ s.i }, id{ ++counter } {
        std::cout << "copy ctor " << id << "\n";
    }

    Data& operator=(const Data& data) {
        i = data.i;
        std::cout << "copy assign " << data.id << " to " << id << "\n";
        return *this;
    }

    ~Data() {
        std::cout << "dtor " << id << "\n";
    }
};

不具名返回值优化 URVO

不具名返回值优化发生在返回一个无名对象或者临时对象, 一般是 Return 语句中直接创建并返回的对象.

URVO 从 C++98 Section 12.2 of that standard 开始已被许可, 但是一直到 C++17 编译器才强制返回值优化. 为了观察是否发生返回值优化, 无优化的例子使用 C++14 编译 (编译器版本为: x86-64 gcc 12.2, 由于编译器默认开启该优化选项, 因此需要在编译时关闭该选项), 编译选项如下:

标题编译选项
无优化 C++11/14-Wall -Wextra -pedantic -fno-elide-constructors
优化 C++17/20-Wall -Wextra -pedantic

代码示例 1

Data GetData() {
    return Data{};
}

int main() {
    Data d = GetData();    
    return 0;
}

可以看出, 在 RVO 时, 对象只被构造了一次. 而未 RVO 时, 对象则发生了多次拷贝 (注 [坑, 待填]: 第三次拷贝构造和赋值右值相关, 在 特殊成员函数 系列中有提及, 如果类遵循 Rule of Big Five, 这里不会发生拷贝构造, 而是移动赋值).

  • 无优化 (运行代码)

    Data GetData() {
        return Data{};         // ctor 1
    }                          // copy ctor 2, dtor 1
    
    int main() {
        Data d = GetData();   // copy ctor 3, dtor 2
        return 0;
    }                         // dtor 3
    
  • 优化后 (运行代码)

    Data GetData() {
        return Data{};         // ctor 1
    }                          
    
    int main() {
        Data d = GetData();   
        return 0;
    }                         // dtor 1
    

具名返回值优化 RVO

具名返回值优化一般发生在返回一个已经创建的对象.

GCC 默认开启具名返回值优化, 包括 C++98. 因此编译时需显式禁用选项, 编译选项如下:

标题编译选项
无优化 C++11/C++14-Wall -Wextra -pedantic -fno-elide-constructors
无优化 C++17/20-Wall -Wextra -pedantic -fno-elide-constructors
优化 C++11/14/17/20-Wall -Wextra -pedantic

此外, 需要注意的是, MSVC 默认是不开启具名返回值优化的, 若要开启优化, 需要在 /O2 下编译.

代码示例 2

Data GetData() {
    Data d;
    d.i = 999;
    return d;
}

int main() {
    Data d = GetData();    
    return 0;
}

输出

  • 无优化 C++11/C++14, (运行代码)

    Data GetData() {
        return Data{};         // ctor 1
    }                          // copy ctor 2, dtor 1
    
    int main() {
        Data d = GetData();   // copy ctor 3, dtor 2
        return 0;
    }                         // dtor 3
    
  • 无优化 C++17/20, (运行代码)

    Data GetData() {
        return Data{};         // ctor 1
    }                          // copy ctor 2, dtor 1
    
    int main() {
        Data d = GetData();
        return 0;
    }                         // dtor 2
    
  • 优化 C++11/14/17/20 (运行代码)

    Data GetData() {
        return Data{};         // ctor 1
    }                          
    
    int main() {
        Data d = GetData();   
        return 0;
    }                         // dtor 1
    

容器的返回值优化

对于容器来说, 若整个容器发生拷贝, 代价很高. 因此, 非常有必要考虑返回值优化. 一般来说, C++ 默认对容器生效.

std::vector<Data> GetDataContainers() {
    //!!! case 1
    // return std::vector<Data>{Data{}};    // ctor 1, copy ctor 2, dtor 1

    //!!! case 2
    std::vector<Data> vec{Data{}};          // ctor 1, copy ctor 2, dtor 1
    return vec;
}                                          

int main() {
    auto d = GetDataContainers();    
    return 0;
}                                           // dtor 2

从 C++17 开始, 编译器强制开启返回值优化后, 即使 -fno-elide-constructors, 优化仍然发生.

运行代码

ctor 1
copy ctor 2
dtor 1
dtor 2

在 C++14 及其之前版本中, 在 -fno-elide-constructors 下编译输出:

运行代码

ctor 1
copy ctor 2
copy ctor 3
copy ctor 4
dtor 3
dtor 2
dtor 1
dtor 4

返回值优化的特殊情况

对于存在分支的函数, 若所有分支都返回同一个具名对象, 才会开启返回值优化. 编译选项: -std=c++20 -Wall -Wextra -pedantic.

代码示例 3 - 所有分支返回同一具名对象 (优化)

若分支返回全是同一具名对象, 发生返回值优化. 运行代码

Data GetData(int param) {
    Data d;                     // ctor 1
    
    if (param % 2 == 0) {
        d.i = 1;
        return d;
    }
    else if (param % 2 == 1) {
        d.i = 2;
        return d;
    }

    return d;
}

int main() {
    Data d = GetData(0);    
    return 0;
}                               // dtor 2

代码示例 4 - 所有分支返回非同一对象 (无优化)

若分支返回不全是同一具名对象, 则无返回值优化. 因为返回的对象在运行时确定, 编译器无法在编译期决定.

运行代码

Data GetData(int param) {
    Data d;                     // ctor 1
    
    if (param % 2 == 0) {
        d.i = 1;
        return d;
    }
    else if (param % 2 == 1) {
        Data d2;
        d2.i = 2;
        return d2;
    }

    return d;
}                               // copy ctor 2, dtor 1

int main() {
    Data d = GetData(0);    
    return 0;
}                               // dtor 2

运行代码

Data GetData(int param) {
    if (param % 2 == 0) {
        Data d1;                 // ctor 1
        d1.i = 1;
        return d1;
    } else {
        Data d2;                 // ctor 1
        d2.i = 2; 
        return d2;
    }
}                               // copy ctor 2, dtor 1

int main() {
    Data d = GetData(0);    
    return 0;
}                               // dtor 2

运行代码

Data GetData(int param) {
    Data d1, d2;                // ctor 1, ctor 2
    
    if (param % 2 == 0) {
        return d1;
    } else {
        return d2;
    }
}                               // copy ctor 3, dtor 2, dtor 1

int main() {
    Data d = GetData(0);    
    return 0;
}                               // dtor 3

代码示例 5 - 函数返回结果用于赋值 (无优化)

需要注意的另一种情况是, 如果调用函数时, 造成的是拷贝赋值, 而不是拷贝构造, 即使是不具名的情况, 也不会发生返回值优化 (注: 换个思路理解, 编译器不清楚赋值左侧的值从创建到赋值之间, 将处于何种状态, 或者进行何种操作, 所以不会对这种形式做返回值优化. 为避免这种情况的拷贝赋值, 可以通过移动赋值来消除).

运行代码

Data GetData(int param) {
    //!!! sub case 1
    // Data d{};            // ctor 2
    // return d;

    //!!! sub case 2
    // return Data{};       // ctor 2

    //!!! sub case 3
    Data d{};               // ctor 2
    if (param % 2 == 0) {
        d.i = 1;
        return d;
    } else {
        d.i = 2;
        return d;
    }
}

int main() {
    Data d;                 // ctor 1
    d = GetData(0);         // copy assign 2 to 1
    return 0;
}                           // dtor 2, dtor1

代码示例 6 - 函数返回成员对象 (无优化)

函数返回的是局部对象的成员变量, 也无法作用返回值优化, 即使是匿名变量.

运行代码

struct DataWrap {
    Data d;
};

Data GetData() {
    return DataWrap{}.d;        // ctor 1
}                               // copy ctor 2, dtor 1

int main() {
    Data d = GetData();    
    return 0;
}                               // dtor 2

代码示例 7 - 函数返回参数或者全局变量 (无优化)

函数返回的是输入参数或者全局变量, 也无返回值优化.

运行代码

Data GetData(Data d) {
    return d;        
}                               // copy ctor 3

int main() {
    Data d1{};                  // ctor 1   
    Data d2 = GetData(d1);      // copy ctor 2   
    return 0;
}                               // dtor 2, dtor 3, dtor2

运行代码

Data sd{};                  // ctor 1

Data GetData() {
    return sd;                  
}                           // copy ctor 2, dtor 1

int main() {
    Data d = GetData();    
    return 0;
}                           // dtor 2

代码示例 8 - 由 std::move 返回 (无优化)

通过显式调用 std::move 返回函数结果往往是错误的. 即使如此, 这试图使对象显式调用移动构造函数, 导致返回值优化被抑制.

运行代码

Data GetData() {
    Data d;                 // ctor 1
    return std::move(d);                  
}                           // copy ctor 2, dtor 1

int main() {
    Data d = GetData();    
    return 0;
}                           // dtor 2

异常 (try-catch) 中的返回

[待填]

参考资料