原文链接:

OOP58-CPP. Copy operations must not mutate the source object


拷贝操作(拷贝构造函数和拷贝赋值运算符)被用于将源对象显著的属性拷贝到目的对象中去,使后者称为源对象的一份“副本”。所谓类型的显著属性是依赖于类型的,但是对于暴露了比较或者等式操作符的类型,则包括这比较操作中的所有属性。由此期望可假设拷贝操作使得目的对象和源对象的值表达是相等的。违背这条基本假设会造成意料之外的行为。

理想情况下,拷贝操作应该有一套惯用的签名。对于拷贝构造函数,则是 T(const T&) ;对于拷贝赋值操作符,则是 T& operator=(const T&);对应地,未用惯用签名的拷贝构造函数和拷贝赋值操作符不满足 CopyConstructibleCopyAssignable 概念的要求。这将阻碍类型使用通用标准库的功能 [ISO/IEC 14882-2014].

当实现一个拷贝操作符时,不要改变任一源对象操作数的外部可观测的成员或者全局访问的信息。外部可观测成员包括但不限于参与比较或相等操作的成员,通过公共 APIs 暴露出值的成员,还有全局变量。

在 C++11 之前,改变源操作数的拷贝操作是用来提供 move-like 语义的唯一途径。然而,语言并没有提供一种方法,能确保这种操作只在原操作数处于其生命周期终结时发生,这造成像 std::auto_ptr 这种脆弱的 APIs。在 C++11 及其之后,移动操作更适合应对这种情况,而非拷贝操作。

auto_ptr

举个例子, 在 C++03 中, std::auto_ptr 有如下的拷贝操作签名 [ISO/IEC 14882-2003]:

拷贝赋值 auto_ptr& operator=(auto_ptr &A);
拷贝构造 auto_ptr(auto_ptr &A);

拷贝构造和拷贝赋值通过调用 this->reset(A.release()) 将会改变源参数,A。然而,这使得标准库算法所作的假设失效,例如 std::sort(),可能需要为后续的比较创建对象的副本 [Hinnant 05]。考虑如下实现 快排,quick sort 算法的 std::sort() 的实现。

1
2
3
// ...
value_type pivot_element = *mid_point;
// ...

在这节点上,排序算法假设 pivot_element*mid_point 有等价的值表达,并将比较是否相等。然而,对于 std::auto_ptr 来说不适用,因为 *mid_point 已经被修改,导致了意外的行为。

在 C++11 中,引入了智能指针类 std::unique_ptr 来代替 std::auto_ptr ,使之能更好地规定指针对象地所有权语义。std::unique_ptr 显式地删除了拷贝构造函数和拷贝赋值操作符,取而代之使用移动构造函数和移动赋值操作,而不是在拷贝操作时改变源参数。由此,std::auto_ptr 在 C++11 中被弃用。

不合规代码示例

在这个不合规代码示例中,A 的拷贝操作,将其成员变量 m 重置为 0 ,改变了源操作数。当 std::fill() 被调用时,首个被拷贝的元素将拥有 obj.m 的初始值,12,此时 obj.m 被置为 0。后续的九个副本将全都为 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <algorithm>
#include <vector>

class A {
mutable int m;

public:
A() : m(0) {}
explicit A(int m) : m(m) {}

A(const A &other) : m(other.m) {
other.m = 0;
}

A& operator=(const A &other) {
if (&other != this) {
m = other.m;
other.m = 0;
}
return *this;
}

int get_m() const { return m; }
};

void f() {
std::vector<A> v{10};
A obj(12);
std::fill(v.begin(), v.end(), obj);
}

合规方案

在这个合规方案中,A 的拷贝操作不再改变源操作数,以确保容器包含 obj 等价的副本。相反,当修改操作是安全的话,A 已经给出移动操作来执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <algorithm>
#include <vector>

class A {
int m;

public:
A() : m(0) {}
explicit A(int m) : m(m) {}

A(const A &other) : m(other.m) {}
A(A &&other) : m(other.m) { other.m = 0; }

A& operator=(const A &other) {
if (&other != this) {
m = other.m;
}
return *this;
}

A& operator=(A &&other) {
m = other.m;
other.m = 0;
return *this;
}

int get_m() const { return m; }
};

void f() {
std::vector<A> v{10};
A obj(12);
std::fill(v.begin(), v.end(), obj);
}

例外

OOP58-CPP-EX0: 引用计数,还有类似 std::shared_ptr<> 的实现属于这条规则的例外。任何引用计数对象的拷贝或者赋值操作需要增减引用计数。引用计数的语义很好理解,可以认为其不属于 shared_pointer 对象显著的一部分。

Risk Assessment

Copy operations that mutate the source operand or global state can lead to unexpected program behavior. Using such a type in a Standard Template Library container or algorithm can also lead to undefined behavior.

Rule Severity Likelihood Remediation Cost Priority Level
OOP58-CPP Low Likely Low P9 L2

Automated Detection

Tool Version Checker Description
Helix QAC 2022.1 C++4075
Klocwork 2022.1 CERT.OOP.COPY_MUTATES
Parasoft C/C++test 2021.2 CERT_CPP-OOP58-a Copy operations must not mutate the source object
Polyspace Bug Finder R2021b CERT C++: OOP58-CPP Checks for copy operation modifying source operand (rule partially covered)
PRQA QA-C++ 4.4 4075

Search for vulnerabilities resulting from the violation of this rule on the CERT website.

SEI CERT C++ Coding Standard OOP54-CPP. Gracefully handle self-copy assignment

Bibliography

[ISO/IEC 14882-2014] Subclause 12.8, “Copying and Moving Class Objects” Table 21, “CopyConstructible Requirements” Table 23, “CopyAssignable Requirements”
[ISO/IEC 14882-2003]
[Hinnant 2005] “Rvalue Reference Recommendations for Chapter 20”

img img img