这篇讨论的是如何在函数返回两个值
cherno推荐的方法是创建一个结构体并返回它
代码预备#
为了演示,本篇使用了string,为了顺便探讨性能(视频中没提到),我写了一个string.h 和string.cpp打印相关信息
//String.h
#pragma once // 防止头文件被重复包含
#include <iostream>
class String
{
private:
char* m_Buffer;
unsigned int m_Size;
public:
// 默认构造函数
String() : m_Buffer(nullptr), m_Size(0) {}
// 构造函数
String(const char* string);
// 拷贝构造函数(深拷贝)
//拷贝构造函数 (String a = b;):当你在创建一个新的对象,并用已有的对象初始化它时调用。
String(const String& other);
// 析构函数
~String();
// 运算符重载
char& operator[](unsigned int index);
// 赋值运算符重载 (a = b;):当两个对象都已经存在(已经构造完毕),你只是想把其中一个的值覆盖给另一个时调用。
String& operator=(const String& other);
// 友元函数:重载 << 运算符
friend std::ostream& operator<<(std::ostream& stream, const String& string);
// 测试函数
unsigned int& mytest() { return m_Size; } // 简单的内联函数可以直接写在类内
};#include "String.h"
#include <cstring> // 必须包含 cstring 才能使用 strlen 和 memcpy
// 构造函数
String::String(const char* string)
{
m_Size = strlen(string);
m_Buffer = new char[m_Size + 1];
memcpy(m_Buffer, string, m_Size);
m_Buffer[m_Size] = '\0';
}
// 拷贝构造函数
String::String(const String& other)
: m_Size(other.m_Size)
{
std::cout << "Copied String!" << std::endl;
m_Buffer = new char[m_Size + 1];
memcpy(m_Buffer, other.m_Buffer, m_Size + 1);
}
//赋值运算符
String& String::operator=(const String& other)
{
std::cout << "Assignment Operator Called!" << other << std::endl;
// 1. 自赋值检查 (防止 s1 = s1 的骚操作)
if (this == &other)
return *this;
// 2. 释放当前对象旧的内存,防止内存泄漏
delete[] m_Buffer;
// 3. 申请新空间并拷贝内容
m_Size = other.m_Size;
m_Buffer = new char[m_Size + 1];
memcpy(m_Buffer, other.m_Buffer, m_Size + 1);
// 4. 返回对象自身,支持链式赋值 (a = b = c)
return *this;
}
// 析构函数
String::~String()
{
delete[] m_Buffer;
}
// 下标运算符重载
char& String::operator[](unsigned int index)
{
return m_Buffer[index];
}
// 友元函数定义(注意:定义时不需要加 String::,也不需要加 friend 关键字)
std::ostream& operator<<(std::ostream& stream, const String& string)
{
stream << string.m_Buffer;
return stream;
}方法1:使用引用#
#include <iostream>
#include "String.h"
void ParseShader(String& vertexSource, String& fragmentSource)
{
//假设vs,fs最终计算值是abc、def
String vs = "abc";
String fs = "def";
//这两行代码执行的是 字符串赋值(内部会拷贝)
vertexSource = vs;
fragmentSource = fs;
}
int main()
{
String vs, fs;
ParseShader(vs, fs);
std::cout << vs << std ::endl;
std::cout << fs << std::endl;
std::cin.get();
}
/*
Assignment Operator Called!abc
Assignment Operator Called!def
abc
def
*/方法2:使用指针#
#include <iostream>
#include "String.h"
void ParseShader(String* vertexSource, String* fragmentSource)
{
//假设vs,fs最终计算值是abc、def
String vs = "abc";
String fs = "def";
//这两行代码执行的是 字符串赋值(内部会拷贝)
if (vertexSource) {
*vertexSource = vs;
}
if (fragmentSource) {
*fragmentSource = fs;
}
}
int main()
{
String vs, fs;
ParseShader(&vs, &fs);
//这种方式允许空指针
//ParseShader(nullptr, &fs);
std::cout << vs << std::endl;
std::cout << fs << std::endl;
std::cin.get();
}
/*
Assignment Operator Called!abc
Assignment Operator Called!def
abc
def
*/(同类型)方法3:返回(数组)指针#
#include <iostream>
#include "String.h"
String* ParseShader()
{
//假设vs,fs最终计算值是abc、def
//vs,fs先在栈上创建(但String成员m_Buffer是指向堆)
String vs = "abc";
String fs = "def";
//这种语法在c++20才能编译通过
return new String[]{ vs,fs };
}
int main()
{
//这里不知道数组有多大
String* result =ParseShader();
std::cout << result[0] << std::endl;
std::cout << result[1] << std::endl;
std::cin.get();
}
/*
Assignment Operator Called!abc
Assignment Operator Called!def
abc
def
*/分析:
- 准备阶段: vs 和 fs 在栈上出生,它们分别在堆上申请了空间存放 “abc” 和 “def”。
- 关键动作:return new String[]{ vs, fs };
- 堆分配:在堆上开辟了一块新空间,足以存放两个 String 对象。
- 拷贝构造:调用 String 的拷贝构造函数,把 vs 和 fs 的内容“复制”到这块新堆空间里。
- 重点:此时,内存里有两份 “abc” 和 两份 “def”(都在堆上)。
- 函数结束(执行到 }):
- 局部变量 vs 和 fs 的寿命到了,它们的析构函数被触发。
- vs 析构函数执行 delete[] m_Buffer,销毁了它原本的那份 “abc”。
- 但是,刚才 new 出来的那个数组里的 “abc” 依然存在,因为它属于另一块堆内存。
(同类型)方法4:返回array#
#include <iostream>
#include <array>
#include "String.h"
std::array<String,2> ParseShader()
{
//假设vs,fs最终计算值是abc、def
//vs,fs先在栈上创建(但String成员m_Buffer是指向堆)
String vs = "abc";
String fs = "def";
std::array<String, 2> results;
//触发了 String 类的拷贝赋值运算符(Assignment Operator)
//此时 results[0] 内部的 m_Buffer 会申请一块新堆内存,并将 vs 指向的内容复制过来。现在内存中有两份 "abc"。
results[0] = vs;
results[1] = fs;
//由于现代编译器的 RVO(返回值优化),results 数组通常不会被拷贝,而是直接在 main 函数的 result 变量位置“原地出生”。且result指向的是上面申请的新堆内存
return results;
}
int main()
{
std::array<String, 2> result = ParseShader();
std::cout << result[0] << std::endl;
std::cout << result[1] << std::endl;
std::cin.get();
}
/*
Assignment Operator Called!abc
Assignment Operator Called!def
abc
def
*/解释一下如果没有RVO优化
第一步:从 results (原件) 到 “中转站” (副本1) 当执行 return results; 时:
- 编译器需要在中转区构造一个 std::array<String, 2> 的临时对象。
- 因为 std::array 的拷贝本质上是循环对其成员进行拷贝,所以它会调用 results[0] 和 results[1] 的拷贝构造函数。
- String 的表现:你的深拷贝代码被触发!
- 申请新堆内存,把 “abc” 拷进去。
- 申请新堆内存,把 “def” 拷进去。
- 状态:此时内存里存在三份数据(局部变量、数组原件、中转站副本)。
第二步:函数销毁(清理原件) ParseShader 的栈帧销毁。
- 局部变量 vs/fs 以及 results 数组被析构。
- String 的表现:触发析构函数,delete[] 掉它们持有的堆内存。
- 结果:最初的那两份堆内存消失了,但“中转站”里的那份副本还活着。
第三步:从 “中转站” 到 main 的 result (最终目的地) 回到 main 函数执行 std::array<String, 2> result = ParseShader();:
- 编译器需要把中转站里的临时对象拷贝给 result。
- String 的表现:再次触发深拷贝!
- 再次申请新堆内存,再次 memcpy 拷贝 “abc” 和 “def”。
- 结果:又产生了一套全新的堆内存。
第四步:清理中转站
- 临时对象完成任务,被销毁。
- 执行析构函数,释放中转站刚才持有的堆内存。
(同类型)方法5:返回vector#
#include <iostream>
#include <vector>
#include "String.h"
std::vector<String> ParseShader()
{
//假设vs,fs最终计算值是abc、def
//vs,fs先在栈上创建(但String成员m_Buffer是指向堆)
String vs = "abc";
String fs = "def";
std::vector<String> results;
//触发了 String 类的拷贝函数
results.push_back(vs);//第一次拷贝
results.push_back(fs); //容量不够触发了第二次和第三次拷贝
return results;
}
int main()
{
std::vector<String> result = ParseShader();
std::cout << result[0] << std::endl;
std::cout << result[1] << std::endl;
std::cin.get();
}
为了和方法4对比,这里做一个分析 针对 return results;
- 远古时代(C++98/03):一定会深度拷贝 在这个时代,没有“移动语义”这个概念。
- 执行逻辑:当 return results; 发生且 RVO 失败时,vector 必须保证数据的安全。因为它不知道自己是要被“移动”还是“复制”,所以它只能选择最稳妥的办法——克隆。
- 后果:
- 从 results 拷贝到“中转站”:vector 调用 String 的拷贝构造函数。String 申请新空间,memcpy 内容。(深拷贝 1)
- 从中转站拷贝到 result:再次调用 String 的拷贝构造函数。(深拷贝 2)
- 结论:在这个时代,你的分析是完全正确的
一共多出了两次深度拷贝。
- 现代时代(C11 到 C20):只复制指针 这是你目前所处的时代。引入了 “移动语义(Move Semantics)”。
- 执行逻辑:
- C++ 标准规定,当一个局部变量(如 results)被 return 时,它被视为一个“即将消失的东西”(右值)。
- 编译器会优先寻找 vector 和 String 的移动构造函数,而不是拷贝构造函数。
- 物理动作:
- 移动 vector:直接把 results 里的三个指针(钥匙)复制给中转站,然后把 results 里的指针置为空。
- 移动 String:vector 在转移过程中,会让里面的 String 也执行移动。String 的移动构造函数只是把 m_Buffer 的地址传给新对象。
- 结论:此时只复制了指针(约 24 字节),完全没有 new[] 和 memcpy。 即使 RVO 失败,性能依然极高。
- 超现代时代:RVO 强制执行 在 C17 及以后(你用的是 C20),情况变得更加极端。
- 强制优化:对于某些特定的返回场景,标准规定编译器必须执行 RVO(这种强制的 RVO 叫 Guaranteed Copy Elision)。
- 物理动作:
- 连“中转站”这个概念都被取消了。
- results 变量从出生那天起,就直接住在了 main 函数为 result 预留的地址里。
- 结论:指针拷贝次数 = 0,String 深拷贝次数 = 0。
方法5:元组#
#include <iostream>
#include <utility>
#include "String.h"
std::tuple<String,String> ParseShader()
{
//假设vs,fs最终计算值是abc、def
String vs = "abc";
String fs = "def";
//std::make_pair 是一个按值传递(Pass by Value)的函数。
return std::make_pair(vs,fs);
}
int main()
{
//std::tuple<String, String> result = ParseShader();
ParseShader();
std::cout << "======" << std::endl;
/*std::cout << std::get<0>(result) << std::endl;
std::cout << std::get<1>(result) << std::endl; */
std::cin.get();
}
/*
Copied String!abc
Copied String!def
Copied String!def
Copied String!abc
======
*/当编译器执行到 return std::make_pair(vs, fs); 时,逻辑流如下:
- 构造临时 Pair:std::make_pair 首先在函数内部构造出一个 std::pair<String, String> 对象。为了填满这个 pair,它会从参数中拷贝构造 vs 和 fs(这就是你看到的前两次输出)。
- 发现类型不匹配:编译器看到函数需要返回 tuple,但手里拿的是 pair。
- 调用转换构造函数:编译器调用 tuple 的转换构造函数,这个构造函数会执行类似于下面的逻辑:
tuple.member[0] = pair.first;tuple.member[1] = pair.second;
- 触发深拷贝:由于 pair 里的两个 String 都是左值(即它们是有名字的变量,不是临时变量),tuple 在初始化自己的成员时,必须通过 String 的拷贝构造函数把 pair 里的内容“克隆”一份。
- 这一步产生了你看到的后两次 Copied String!。
解释“由于 pair 里的两个 String 都是左值”#
1. 什么是“左值” (Lvalue)?#
在 C++ 中,左值是指那些有明确名字、有持久内存地址的对象。 在你的代码里:
vs和fs是左值。- 当它们被装进
std::pair后,pair.first和pair.second依然是左值。
左值的特性是:它们在当前语句结束后还要继续存在。
2. 为什么左值必须“克隆”?#
想象一下编译器在执行 tuple 转换构造函数时的心理活动:
- 看到来源:编译器发现要从一个
pair里拿数据。 - 身份检查:编译器检查
pair.first(即字符串 “abc”)。它发现这是一个左值(有名字的变量)。 - 安全评估:编译器心想:“这个
pair.first以后可能还要被别人使用,我不能直接把它的堆内存(m_Buffer)偷走。如果我偷走了,原主人的指针就变成nullptr了,程序后面再用它就会崩溃。” - 做出决定:为了绝对安全,编译器只能下令:“调用拷贝构造函数,重新申请一块内存,把内容克隆一份给
tuple。”
3. 如果是“右值”会怎样?#
如果你告诉编译器:“这个对象我再也不用了,你可以随便破坏它”,这就是右值 (Rvalue)。
如果你写 return std::make_tuple(std::move(vs), std::move(fs));:
- std::move 的作用:它把左值
vs强制转换成右值。 - 编译器心态转变:编译器心想:“主人说了这个
vs是个临时工(右值),不用留活路。那我不克隆了,直接把它的m_Buffer指针偷过来给tuple。” - 结果:这就是移动构造。只需要拷贝一个 8 字节的地址,0 次深拷贝。
4. 总结这个“由于”的逻辑链#
- 由于
pair里的String是左值 - 意味着 它们在逻辑上需要保持完整性
- 导致 编译器不敢执行“破坏性”的移动
- 必须 调用拷贝构造函数进行深拷贝。
左值右值进一步解释#
之所以研究左右值,就是为了性能。
- 以前(只有拷贝):不管你是左值还是右值,返回对象时一律按照“信件”处理,必须找个新信箱复印一份(深拷贝)。
- 现在(有了移动):如果你发现对方是一个右值(即将销毁的临时信箱),你可以直接把那个信箱底部的地址标牌拆下来换成自己的。
这就是为什么 std::move 叫“移动”——它把一个原本稳固的“左值”强行标记为“右值”,告诉编译器:“这个信箱我不要了,随便拆!”
总结
- 左:Location(有地盘,能长期住)。
- 右:Read-only / Release(临时路过,用完即焚)。
方法6:二元组、对 (pair)#
#include <iostream>
#include <utility>
#include "String.h"
std::pair<String,String> ParseShader()
{
//假设vs,fs最终计算值是abc、def
String vs = "abc";
String fs = "def";
//std::make_pair 是一个按值传递(Pass by Value)的函数。
return std::make_pair(vs,fs);
}
int main()
{
std::pair<String, String> result = ParseShader();
std::cout << "======" << std::endl;
std::cout << std::get<0>(result) << std::endl;
std::cout << std::get<1>(result) << std::endl;
std::cout << result.first << std::endl;
std::cout << result.second << std::endl;
std::cin.get();
}
/* 注意,这里只调用了一次复制构造函数,和上面方法5的理论分析一致,没有进行std::pair向std::turple的转换
Copied String!abc
Copied String!def
======
abc
def
abc
def
*/方法7:结构#
#include <iostream>
#include <utility>
#include "String.h"
struct ShaderProgramSource
{
String VertexSource;
String FragmentSource;
};
ShaderProgramSource ParseShader()
{
//假设vs,fs最终计算值是abc、def
String vs = "abc";
String fs = "def";
std::cout << "===1===" << std::endl;
return { vs,fs };
}
int main()
{
ShaderProgramSource sps = ParseShader();
std::cout << "==2====" << std::endl;
std::cout << sps.VertexSource << std::endl;
std::cout << sps.FragmentSource << std::endl;
std::cin.get();
}
/*
===1===
Copied String!abc
Copied String!def
==2====
abc
def
*/深拷贝#
- 过程:当你写 { vs, fs } 时,你实际上是在请求编译器构造一个临时的 ShaderProgramSource 结构体。
- 触发拷贝:因为 vs 和 fs 是左值(有名字的局部变量),编译器必须保证它们在 return 语句执行时保持完整。
- 动作:编译器调用了 String 的拷贝构造函数,将 vs 的内容克隆到结构体的 VertexSource 中,将 fs 的内容克隆到 FragmentSource 中。
为什么没有其他拷贝了:
既然 { vs, fs } 构造了一个临时结构体,那么从这个“临时结构体”到 main 函数里的 sps,难道不需要再拷贝一次吗?
不需要。 这里正是 RVO(返回值优化) 发挥作用的地方:
原地构造:编译器并没有真的先在函数里建一个临时结构体再搬走。
- 逻辑合并:它直接在 main 函数为 sps 预留的内存空间里,执行了那两次 String 的拷贝构造。
- 结果:你只看到了进入结构体时的那两次拷贝,而没有看到结构体本身被搬运的拷贝。
将左值转为右值,避免拷贝:#
在String.h新增声明
// 1. 移动构造函数 (String a = std::move(b);)
// 这里的 && 表示右值引用
String(String&& other) noexcept;
// 2. 移动赋值运算符 (a = std::move(b);)--(这个例子中非必要)
String& operator=(String&& other) noexcept;String.app新增定义
//定义移动构造函数
String::String(String&& other) noexcept
: m_Buffer(other.m_Buffer), m_Size(other.m_Size) // 1. 直接接管对方的指针地址
{
std::cout << "Moved String!" << m_Buffer << std::endl;
// 2. 非常关键:将原对象的指针置空
// 如果不置空,当 other 析构时,会 delete 掉我们刚刚偷过来的内存!
other.m_Buffer = nullptr;
other.m_Size = 0;
}
//定义移动赋值函数--(这个例子中非必要)
String& String::operator=(String&& other) noexcept {
if (this != &other) { // 防止自己移动给自己
delete[] m_Buffer; // 先释放自己原有的内存
m_Buffer = other.m_Buffer; // 偷走对方的
m_Size = other.m_Size;
other.m_Buffer = nullptr; // 令对方失效
other.m_Size = 0;
}
return *this;
}Main.cpp修改返回值
ShaderProgramSource ParseShader()
{
//....
//...
//...
return { std::move(vs), std::move(fs) };
}解释#
ShaderProgramSource 这个结构体是在 return 这一刻才开始在内存里“出生”的。
- 既然结构体刚出生,它内部的成员变量 VertexSource 和 FragmentSource 也是第一次被创建。
- 从无到有的过程,必然调用构造函数。
- 只有当对象已经完成了初始化,你再去修改它的值,才会触发赋值函数。