优化编译器可以添加 std::move 吗?
Can an optimizing compiler add std::move?
编译器可以自动左值到右值转换 如果它能证明左值不会被再次使用?这里有一个例子来阐明我的意思:
void Foo(vector<int> values) { ...}
void Bar() {
vector<int> my_values {1, 2, 3};
Foo(my_values); // may the compiler pretend I used std::move here?
}
如果将 std::move
添加到注释行,则向量可以移动到 Foo
的参数中,而不是复制。但是,正如所写,我没有使用 std::move
。
静态地证明 my_values 不会在注释行之后使用是很容易的。那么编译器允许移动向量,还是需要复制它?
编译器的行为就像复制发生在从 vector
到 Foo
的调用一样。
如果编译器可以证明存在一个有效的抽象机器行为,没有可观察到的副作用(在抽象机器行为中,而不是在真实的计算机中!)涉及将 std::vector
移动到 Foo
,它可以做到这一点。
在你上面的例子中,这个(移动没有抽象机器可见的副作用)是正确的;但是,编译器可能无法证明这一点。
复制 std::vector<T>
时可能观察到的行为是:
- 在元素上调用复制构造函数。
int
无法观察到
- 在不同时间调用默认
std::allocator<>
。这会调用 ::new
和 ::delete
(maybe1) 无论如何,::new
和 ::delete
在上面的程序中没有被替换,所以你不能在标准下观察到这一点。
- 在不同对象上多次调用
T
的析构函数。 int
. 无法观察到
- 调用
Foo
后vector
非空。没有人检查它,所以它是空的,就好像它不是。
- 指向外部向量元素的引用、指针或迭代器与内部向量不同。
Foo
. 之外的向量元素没有引用、向量或指针
虽然你可能会说 "but what if the system is out of memory, and the vector is large, isn't that observable?":
抽象机没有 "out of memory" 条件,它只是出于非约束原因有时分配失败(抛出 std::bad_alloc
)。它 not 失败是抽象机的有效行为,并且不通过不分配(实际)内存(在实际计算机上)而失败也是有效的,只要不存在内存没有明显的副作用。
稍微多一点的玩具箱:
int main() {
int* x = new int[std::size_t(-1)];
delete[] x;
}
虽然这个程序显然分配了太多内存,但编译器可以不分配任何东西。
我们可以走得更远。甚至:
int main() {
int* x = new int[std::size_t(-1)];
x[std::size_t(-2)] = 2;
std::cout << x[std::size_t(-2)] << '\n';
delete[] x;
}
可以变成std::cout << 2 << '\n';
。那个大缓冲区必须抽象存在,但只要你的"real"程序表现得像抽象机器那样,它实际上不必分配它。
不幸的是,很难以任何合理的规模这样做。从 C++ 程序中泄漏信息的方式有很多种。所以依赖这样的优化(即使它们发生了)不会有好的结果。
1 关于合并调用 new
的一些东西可能会混淆问题,我不确定跳过调用是否合法,即使有替换为 ::new
.
一个重要的事实是,在某些情况下编译器 不需要 表现得好像有一个副本,即使 std::move
没有被调用。
当你 return
函数中的局部变量在一行中看起来像 return X;
并且 X
是标识符,并且该局部变量具有自动存储持续时间(在堆栈),该操作隐含地是一个移动,并且编译器(如果可以的话)可以将 return 值和局部变量的存在省略到一个对象中(甚至省略 move
)。
当你从一个临时对象构造一个对象时也是如此——这个操作隐式地是一个移动(因为它绑定到一个右值)并且它可以消除完全移动。
在这两种情况下,编译器都需要将其视为移动(而不是副本),并且可以忽略移动。
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return x;
}
那个x
没有std::move
,但它被移动到return值中,并且该操作可以省略(x
和return 值可以变成一个对象)。
这个:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return std::move(x);
}
阻止省略,就像这样:
std::vector<int> foo(std::vector<int> x) {
return x;
}
我们甚至可以阻止移动:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return (std::vector<int> const&)x;
}
甚至:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return 0,x;
}
因为隐式移动的规则是故意脆弱的。 (0,x
是备受诟病的 ,
运算符的用法)。
现在,不建议依赖隐式移动不会发生在像最后一个基于 ,
的情况下:标准委员会已经将隐式复制案例更改为隐式移动,因为隐式移动被添加到语言中是因为他们认为它是无害的(其中函数 return 是一个带有 A(B&&)
构造函数的类型 A
,并且 return 语句是 return b;
其中b
是 B
类型;在 C++11 版本中执行复制,现在它执行移动。)不能排除隐式移动的进一步扩展:显式转换为 const&
可能是现在和将来最可靠的预防方法。
在这种情况下,编译器可以移出my_values
。这是因为这不会导致 可观察行为 .
的差异
引用 C++ 标准对可观察行为的定义:
The least requirements on a conforming implementation are:
- Access to volatile objects are evaluated strictly according to the rules of the abstract machine.
- At program termination, all data written into files shall be identical to one of the possible results that execution of the program according to the abstract semantics would have produced.
- The input and output dynamics of interactive devices shall take place in such a fashion that prompting output is actually delivered before a program waits for input. What constitutes an interactive device is implementation-defined.
稍微解释一下:"files"这里包括标准输出流,对于 C++ 标准未定义的函数调用(例如操作系统调用,或对第三方库的调用),它必须假定这些函数可能会写入文件,因此由此得出的必然结果是非标准函数调用也必须被视为可观察到的行为。
但是您的代码(如您所示)没有 volatile
变量,也没有调用非标准函数。所以这两个版本(移动或不移动)必须具有相同的可观察行为,因此编译器可以做任何一个(甚至完全优化函数等)
当然,在实践中,编译器要证明没有非标准的函数调用一般不是那么容易的,所以很多像这样的优化机会都被错过了。例如,在这种情况下,编译器可能还不知道默认值 ::operator new
是否已被生成输出的函数替换。
编译器可以自动左值到右值转换 如果它能证明左值不会被再次使用?这里有一个例子来阐明我的意思:
void Foo(vector<int> values) { ...}
void Bar() {
vector<int> my_values {1, 2, 3};
Foo(my_values); // may the compiler pretend I used std::move here?
}
如果将 std::move
添加到注释行,则向量可以移动到 Foo
的参数中,而不是复制。但是,正如所写,我没有使用 std::move
。
静态地证明 my_values 不会在注释行之后使用是很容易的。那么编译器允许移动向量,还是需要复制它?
编译器的行为就像复制发生在从 vector
到 Foo
的调用一样。
如果编译器可以证明存在一个有效的抽象机器行为,没有可观察到的副作用(在抽象机器行为中,而不是在真实的计算机中!)涉及将 std::vector
移动到 Foo
,它可以做到这一点。
在你上面的例子中,这个(移动没有抽象机器可见的副作用)是正确的;但是,编译器可能无法证明这一点。
复制 std::vector<T>
时可能观察到的行为是:
- 在元素上调用复制构造函数。
int
无法观察到 - 在不同时间调用默认
std::allocator<>
。这会调用::new
和::delete
(maybe1) 无论如何,::new
和::delete
在上面的程序中没有被替换,所以你不能在标准下观察到这一点。 - 在不同对象上多次调用
T
的析构函数。int
. 无法观察到
- 调用
Foo
后vector
非空。没有人检查它,所以它是空的,就好像它不是。 - 指向外部向量元素的引用、指针或迭代器与内部向量不同。
Foo
. 之外的向量元素没有引用、向量或指针
虽然你可能会说 "but what if the system is out of memory, and the vector is large, isn't that observable?":
抽象机没有 "out of memory" 条件,它只是出于非约束原因有时分配失败(抛出 std::bad_alloc
)。它 not 失败是抽象机的有效行为,并且不通过不分配(实际)内存(在实际计算机上)而失败也是有效的,只要不存在内存没有明显的副作用。
稍微多一点的玩具箱:
int main() {
int* x = new int[std::size_t(-1)];
delete[] x;
}
虽然这个程序显然分配了太多内存,但编译器可以不分配任何东西。
我们可以走得更远。甚至:
int main() {
int* x = new int[std::size_t(-1)];
x[std::size_t(-2)] = 2;
std::cout << x[std::size_t(-2)] << '\n';
delete[] x;
}
可以变成std::cout << 2 << '\n';
。那个大缓冲区必须抽象存在,但只要你的"real"程序表现得像抽象机器那样,它实际上不必分配它。
不幸的是,很难以任何合理的规模这样做。从 C++ 程序中泄漏信息的方式有很多种。所以依赖这样的优化(即使它们发生了)不会有好的结果。
1 关于合并调用 new
的一些东西可能会混淆问题,我不确定跳过调用是否合法,即使有替换为 ::new
.
一个重要的事实是,在某些情况下编译器 不需要 表现得好像有一个副本,即使 std::move
没有被调用。
当你 return
函数中的局部变量在一行中看起来像 return X;
并且 X
是标识符,并且该局部变量具有自动存储持续时间(在堆栈),该操作隐含地是一个移动,并且编译器(如果可以的话)可以将 return 值和局部变量的存在省略到一个对象中(甚至省略 move
)。
当你从一个临时对象构造一个对象时也是如此——这个操作隐式地是一个移动(因为它绑定到一个右值)并且它可以消除完全移动。
在这两种情况下,编译器都需要将其视为移动(而不是副本),并且可以忽略移动。
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return x;
}
那个x
没有std::move
,但它被移动到return值中,并且该操作可以省略(x
和return 值可以变成一个对象)。
这个:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return std::move(x);
}
阻止省略,就像这样:
std::vector<int> foo(std::vector<int> x) {
return x;
}
我们甚至可以阻止移动:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return (std::vector<int> const&)x;
}
甚至:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return 0,x;
}
因为隐式移动的规则是故意脆弱的。 (0,x
是备受诟病的 ,
运算符的用法)。
现在,不建议依赖隐式移动不会发生在像最后一个基于 ,
的情况下:标准委员会已经将隐式复制案例更改为隐式移动,因为隐式移动被添加到语言中是因为他们认为它是无害的(其中函数 return 是一个带有 A(B&&)
构造函数的类型 A
,并且 return 语句是 return b;
其中b
是 B
类型;在 C++11 版本中执行复制,现在它执行移动。)不能排除隐式移动的进一步扩展:显式转换为 const&
可能是现在和将来最可靠的预防方法。
在这种情况下,编译器可以移出my_values
。这是因为这不会导致 可观察行为 .
引用 C++ 标准对可观察行为的定义:
The least requirements on a conforming implementation are:
- Access to volatile objects are evaluated strictly according to the rules of the abstract machine.
- At program termination, all data written into files shall be identical to one of the possible results that execution of the program according to the abstract semantics would have produced.
- The input and output dynamics of interactive devices shall take place in such a fashion that prompting output is actually delivered before a program waits for input. What constitutes an interactive device is implementation-defined.
稍微解释一下:"files"这里包括标准输出流,对于 C++ 标准未定义的函数调用(例如操作系统调用,或对第三方库的调用),它必须假定这些函数可能会写入文件,因此由此得出的必然结果是非标准函数调用也必须被视为可观察到的行为。
但是您的代码(如您所示)没有 volatile
变量,也没有调用非标准函数。所以这两个版本(移动或不移动)必须具有相同的可观察行为,因此编译器可以做任何一个(甚至完全优化函数等)
当然,在实践中,编译器要证明没有非标准的函数调用一般不是那么容易的,所以很多像这样的优化机会都被错过了。例如,在这种情况下,编译器可能还不知道默认值 ::operator new
是否已被生成输出的函数替换。