0882reachableSubNodes 细分图中的可到达结点

1 题目

https://leetcode.cn/problems/reachable-nodes-in-subdivided-graph/

2 解题思路

  • 1 解题思路:类似的题目:https://leetcode.cn/problems/partition-array-into-two-arrays-to-minimize-sum-difference/
    • 1.1 dijstra算出来到所有顶点的距离
    • 1.2 最终能到达的节点由两部分构成:
      • a : 来自原始节点,要求距离小于maxMoves
      • b : 来自细分节点:很简单,对于每个边uv,从u和v出发,分别还能前进maxMoves - dis[u], maxMoves - dis[v],这就是细分节点的个数
        • 需要注意两点:1,maxMoves不一定比dis[u]大;2,若从u和v出发加起来的细分节点数大于uv之间所有的细分节点,记得clamp到uv之间的细分节点个数
  • 2 回顾dijstra算法:
    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
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    unordered_map<int, unordered_map<int, int>> g;

    auto cmp = [](const pair<int, int>& a, const pair<int, int>& b) {
    return a.first > b.first;
    };
    priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(cmp)> pq(cmp);

    for(auto& e : edges) {
    int u = e[0];
    int v = e[1];
    int w = e[2];
    g[u][v] = w+1;
    g[v][u] = w+1;
    }

    // init dis
    vector<int> disRes(n, INT_MAX);
    map<int, bool> visited;
    visited[0] = true;
    disRes[0] = 0;
    pq.push({0, 0});

    // contain 0 itSelf!

    int res = 0;


    // dijstra
    while(pq.size() > 0) {
    auto node = pq.top();
    pq.pop();
    int u = node.second;
    int dis = node.first;

    // for disRes[u], every relax will push a new pair, so we need
    // to pass all old relaxed value
    /**
    *
    * eg: [[2,3,4],[1,3,9],[0,2,4],[0,1,1]]
    * 对于上图的dijstra的距离表的松弛过程为:
    0 2 2147483647 2147483647
    0 2 5 2147483647
    0 2 5 12 // 这里,第一次松弛来自节点1,
    0 2 5 10 // 第二次来自节点2,那么这两个松弛结果都会记录在pq里面,为了让后面
    // pq遍历到 0到3距离为12的时候能pass掉,我么你需要将12和 10(存于距离中)做比较,
    // 就可以直到12是第一次松弛的结果,10才是最终松弛的结果,否则如果还有其他节点,
    // 那么12会在10松弛完其他节点接着松弛,那就错了
    // 于是这个continue是必要的
    */
    if(disRes[u] < dis) {
    continue;
    }
    visited[u] = true;
    disRes[u] = dis;

    res += (disRes[u] <= maxMoves);

    // relax nodes by u
    for(auto& neighbor : g[u]) {
    int v = neighbor.first;
    int w = neighbor.second;
    // v has been proceed!
    if(!visited[v] && disRes[v] > disRes[u] + g[u][v]) {
    disRes[v] = disRes[u] + g[u][v];
    pq.push({disRes[v], v});
    // print(disRes);
    }
    }
    };

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
class Solution {
public:
int reachableNodes(vector<vector<int>>& edges, int maxMoves, int n) {
unordered_map<int, unordered_map<int, int>> g;

auto cmp = [](const pair<int, int>& a, const pair<int, int>& b) {
return a.first > b.first;
};
priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(cmp)> pq(cmp);

for(auto& e : edges) {
int u = e[0];
int v = e[1];
int w = e[2];
g[u][v] = w+1;
g[v][u] = w+1;
}

// init dis
vector<int> disRes(n, INT_MAX);
map<int, bool> visited;
visited[0] = true;
disRes[0] = 0;
pq.push({0, 0});
// disRes[0] = 0;
// for(auto& neighbor : g[0]) {
// int to = neighbor.first;
// int w = neighbor.second
// disRes[to] = w;
// pq.push({w, to});
// }

// contain 0 itSelf!
int res = 0;


// dijstra
while(pq.size() > 0) {
auto node = pq.top();
pq.pop();
int u = node.second;
int dis = node.first;

// for disRes[u], every relax will push a new pair, so we need
// to pass all old relaxed value
if(disRes[u] < dis) {
continue;
}
visited[u] = true;
disRes[u] = dis;

res += (disRes[u] <= maxMoves);

// relax nodes by u
for(auto& neighbor : g[u]) {
int v = neighbor.first;
int w = neighbor.second;
// v has been proceed!
if(!visited[v] && disRes[v] > disRes[u] + g[u][v]) {
disRes[v] = disRes[u] + g[u][v];
pq.push({disRes[v], v});
print(disRes);
}
}
}

// count sub nodes
for(auto& e : edges) {
int u = e[0];
int v = e[1];
int w = e[2];
int curSubNodes = 0;
if(visited[u]) {
curSubNodes += max(maxMoves - disRes[u], 0);
}
if(visited[v]) {
curSubNodes += max(maxMoves - disRes[v], 0);
}
curSubNodes = min(curSubNodes, w);
// cout << "curSub/edge: " << curSubNodes << " " << "e: " << u << "->" << v << endl;
res += curSubNodes;
}


return res;
}
void print(vector<int>& dis) {
for(int i = 0; i < dis.size(); ++i) {
cout <<dis[i] << " ";
}cout << "\n";
}
};

杨氏矩阵(young table)

t[i][j] <= t[i+1][j] && t[i][j] <= t[i][j+1]

1755minAbsDiff 最接近目标值的子序列和

1 题目

https://leetcode.cn/problems/closest-subsequence-sum/

2 解题思路

  • 1 解题思路:
    • 1.1 注意求的是子序列,那么我们可以用状态压缩码中的每个bit表示当前数字是否存在,从而求得所有子序列的和
    • 1.2 为了快,我们将数组一分为2,左边和右边求所有子序列的和记作ls,rs,然后都升序排列
    • 1.3 结果就是,在ls[i] + rs[j]中找最接近goal的数字,那不就是杨氏矩阵吗,从左到右从上到下都递增的杨氏矩阵,搜所一个数字的最快方案是O(m + n),而不是mlog(n)
    • 1.4 看一个例子:l = 1 1 4 4, r = 2 5 6 9, 其中xi表示的是第i步剪裁掉的解空间
      1
      2
      3
      4
      5
      6
      7
      8
      /** 
      search target: 7, start form l = 4, r = 2
      l : 1 1 4 4
      r: 2 x1 x1 x1 6
      5 x4 6 8 9
      6 7 x3 x2
      9 x3 x2
      */
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class Solution {
public:
vector<int> makeSubSeqSum(vector<int> nums) {
int n = nums.size();
vector<int> ans(1 << n);
for(int bits = (1 << n) - 1; bits >= 0; --bits) {
int curState = bits;
while(curState != 0) {
int pos = ffs(curState) - 1;
ans[bits] += nums[pos];
curState -= (1 << pos);
}
}
return ans;
}

// vector<int> makeSubSeqSum(vector<int> nums){
// vector<int> ans(1 << nums.size());
// for(int i = 0; i < nums.size(); ++i){
// for(int j = 0; j < (1 << i); ++j){
// ans[j + (1 << i)] = ans[j] + nums[i];
// }
// }
// return ans;
// }



void binSearchAdjTwoAndUpdate(int tar, int r, int goal, vector<int>& leftSet, int& minAbs) {
auto it1 = lower_bound(leftSet.begin(), leftSet.end(), tar);
if(it1 == leftSet.end()) {
int l = *(it1 - 1);
minAbs = min(abs(l + r - goal), minAbs);
} else if(it1 == leftSet.begin()) {
int l = *(it1);
minAbs = min(abs(l + r - goal), minAbs);
} else {
int l1 = *it1;
int l2 = *(it1 - 1);
minAbs = min(abs(l1 + r - goal), minAbs);
minAbs = min(abs(l2 + r - goal), minAbs);
}
}

// 注意题目说的是子序列,而不是子数组
// 考虑复杂度:如果枚举所有子序列,2^40,那么将数组分成两半去枚举:2^20 * 2
int minAbsDifference(vector<int>& nums, int goal) {
int n = nums.size();
int mid = n >> 1;
auto leftSet = makeSubSeqSum(vector<int>{nums.begin(), nums.begin() + mid});
auto rightSet = makeSubSeqSum(vector<int>{nums.begin() + mid, nums.end()});

if(n == 1) {
return min(abs(nums[0] - goal), abs(goal));
}

sort(leftSet.begin(), leftSet.end());
sort(rightSet.begin(), rightSet.end());

int minAbs = INT_MAX;
// // 对于每一个r部分,使用二分找到最近的两个l, nlogn
// for(auto r : rightSet) {
// // |r + l| == |goal| => l = |goal| - r or l = - |goal| - r
// int tar1 = abs(goal) - r;
// int tar2 = - abs(goal) - r;
// binSearchAdjTwoAndUpdate(tar1, r, goal, leftSet, minAbs);
// binSearchAdjTwoAndUpdate(tar2, r, goal, leftSet, minAbs);
// }

// 直接使用双指针,比二分快, o(n)
int ptr1 = leftSet.size() - 1; // 杨氏矩阵的i行 // left升序
int ptr2 = 0; // 央视矩阵的j列 // right升序
// 则 ptr2为r的最大,ptr1为l的最小
/**
search target: 7, start form l = 4, r = 2
l : 1 1 4 4
r: 2 x1 x1 x1 6
5 x4 6 8 9
6 7 x3 x2
9 x3 x2
*/
while(ptr1 >= 0 && ptr2 < rightSet.size()) {
// cout << leftSet[ptr1] << " " << rightSet[ptr2] << endl;
int curRes = leftSet[ptr1] + rightSet[ptr2];
minAbs = min(minAbs, abs(curRes - goal));
if(curRes < goal) {
// 因为所有的 leftSet[ptr1] + rSet[: ptr2-1]都不可能是答案,于是增加ptr1
++ptr2;
} else if(curRes > goal) {
// leftSet[ptr2: ] + leftSet[ptr1] 不可能是答案
--ptr1;
} else {
return 0;
}
}

return min(minAbs, abs(goal));
}
};

0 二分搜索以及实现

我们把普通人的实现:在递增数组里面,搜索target:
最后一定是通过st+=1,使得st == ed然后退出循环,所以我们知道,下面的二分找到的一定是第一个 >= target的值,这也是lower_bound的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ed = peakIdx, st = 0; 
int mid;
while(st < ed) {
mid = (st + ed) >> 1;
int h = mountainArr.get(mid);
if(h < target) { // 注意最后的收敛一定是st += 1去收敛的
st = mid + 1;
} else {
ed = mid;
}
}
if(mountainArr.get(st) == target) {
return st;
}
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
35
36
37
38
39
40
41
42
43
44
45
// lower_bound函数的实现: ------------------------------------------
template<class ForwardIt, class T>
ForwardIt lower_bound(ForwardIt first, ForwardIt last, const T& value)
{
ForwardIt it;
typename std::iterator_traits<ForwardIt>::difference_type count, step;
count = std::distance(first, last);

while (count > 0) {
it = first; // st
step = count / 2; // mid
std::advance(it, step); // vec[mid]
if (*it < value) { // vec[mid] < value
first = ++it; // st = mid + 1
count -= step + 1; // ed mot move, but st move forward,so move count backward
}
else
count = step; // ed = mid
}
return first;
}

// upper_bound函数的实现: ------------------------------------------
// 看到!(value < *it) 也就是 value >= *it
// 这就是说,当*it小于等于目标,那么st++,也就是找到的数为第一个大于value的值
template<class ForwardIt, class T>
ForwardIt upper_bound(ForwardIt first, ForwardIt last, const T& value)
{
ForwardIt it;
typename std::iterator_traits<ForwardIt>::difference_type count, step;
count = std::distance(first, last);

while (count > 0) {
it = first;
step = count / 2;
std::advance(it, step);
if (!(value < *it)) {
first = ++it;
count -= step + 1;
}
else
count = step;
}
return first;
}

1 例题

1157majorityChecker 子数组中占绝大多数的元素

1 题目

https://leetcode.cn/problems/online-majority-element-in-subarray/

2 解题思路

  • 1 解题思路:
    • 1.1 首先统计所有元素的下标
    • 1.2 对于l,r,thres的一个搜索,对所有元素遍历,找出第一个满足要求的元素返回即可:
      • 1.2.1 首先该元素的个数要大于等于thres
      • 1.2.2 在该元素的出现下标的列表中二分搜索,找到第一个大于等于l的下标,和第一个大于r的下标,分别对应到lower_bound和upper_bound
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
class MajorityChecker {
public:
vector<int> vec;
unordered_map<int, vector<int>> toPos;
MajorityChecker(vector<int>& arr) {
int pos = 0;
for(auto& num : arr) {
toPos[num].push_back(pos++);;
}
}

int query(int left, int right, int threshold) {
int res = -1;
for(auto& [num, positions] : toPos) {
if(positions.size() < threshold) {
continue;
}
// for num : find the min and max pos in [left, right]
auto minPos = lower_bound(positions.begin(), positions.end(), left);
auto maxPos = upper_bound(positions.begin(), positions.end(), right);
if (maxPos - minPos >= threshold) {
return num;
}
}

return res;

}
};

1095findInMountainArray 山脉数组中查找目标值

1 题目

https://leetcode.cn/problems/find-in-mountain-array/

2 解题思路

  • 1 解题思路:
    • 1.1 二分法:首先找到山顶,接着在两边分别二分找target就行
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class Solution {
public:
int findInMountainArray(int target, MountainArray &mountainArr) {
int len = mountainArr.length();
int ed = len-1, st = 0;
int peakIdx;
while(st < ed) {
peakIdx = (st + ed) >> 1;
// cout << "st/ed/mid: " << st << " " << ed << " " << peakIdx << endl;
int h = mountainArr.get(peakIdx);
int lh = mountainArr.get(peakIdx - 1);
int rh = mountainArr.get(peakIdx + 1);
if(lh < h && h < rh) {
st = peakIdx + 1;
} else if(lh > h && h > rh){
ed = peakIdx;
} else {
st = peakIdx;
break;
}
}
peakIdx = st;
// cout << "peak is : " << st << " " << mountainArr.get(st) <<endl;

ed = peakIdx, st = 0;
int mid;
while(st < ed) {
mid = (st + ed) >> 1;
int h = mountainArr.get(mid);
if(h < target) {
st = mid + 1;
} else {
ed = mid;
}
}
if(mountainArr.get(st) == target) {
return st;
}

ed = len-1, st = peakIdx;
while(st < ed) {
mid = (st + ed) >> 1;
int h = mountainArr.get(mid);
if(h > target) {
st = mid + 1;
} else {
ed = mid;
}
}
if(mountainArr.get(st) == target) {
return st;
}

return -1;
}
};

0878nthMagicalNumber 第 N 个神奇数字

1 题目

https://leetcode.cn/problems/nth-magical-number/

2 解题思路

  • 1 解题思路:
    • 1.1 经典二分:并不是遍历去搜索,而是在答案的范围内,去二分,对于每各数字判断它和最终要的结果的大小,决定下一个二分的范围
    • 1.2 具体来说,比如这道题:f(x)表示数字x是第几个magicnumber,很容易知道f(x)单调递增,则可以二分
    • 1.3 f(x) = x/a + x/b - x/LCM(a,b)
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
35
36
37
38
39
40
41
42
43
44
class Solution {
public:
long long getLCM(long long x, long long y) {
return x * y / getGCD(x, y);
}

long long getGCD(long long x, long long y) {
long long l = min(x, y);
long long g = max(x, y);
long long mod = g % l;
while(mod != 0) {
g = l;
l = mod;
mod = g % l;
}
return l;
}

constexpr static int MOD = 1'000'000'007;

int nthMagicalNumber(int n, int a, int b) {
long long lcm = getLCM(a, b);
auto getMagicRank = [&](long long x) {
return x / a + x / b - x / lcm;
};
long long st = 0;
long long ed = 1e15;

long long mid = -1;
while(st != ed) {
mid = (st + ed) >> 1;

long long rank = getMagicRank(mid);
// cout << "st/ed/mid: " << st << "/" << ed << "/" << mid << " | " << rank << endl;
if(rank >= n) {
ed = mid;
} else {
st = mid + 1;
}
}

return st % MOD;
}
};

0668findKthNumber 乘法表中第k小的数

1 题目

https://leetcode.cn/problems/kth-smallest-number-in-multiplication-table/

2 解题思路

  • 1 解题思路:
    • 1.1 找乘法表中的第k个数字,应该考虑在1到m*n中,找一个最小的数字mid,小于等于mid的数字,刚好有k个
    • 1.2 为何能够保证,比如对于3*3的乘法表展开:(12233 4669),找的是第7个或者第8个数字,那么二分搜索的过程如下:
    • 1.3 从过程中可以发现,当目前的mid满足了大于等于k个的时候,每次ed = mid,下一个mid就会减小,比如从st/ed的6,9变成6,7,最后搜索到6,也就是,找一个最小的数字,乘法表中有k个小于等于他,最后一定会搜索到它,我们可以反证法:
    • 1.4 假设从1到m*n,找到一个数字有k个小于等于他,记作x,他出现在这个乘法表中,假设它不是最小的,那么也就是说存在一个数字y,y < x出现在乘法表中,且有k个数字小于等于他,很显然是错的,因为这样x,就至少是有k+1个数字小于等于他,和前提相反!
    • st/ed/cnt: 1/9/6 | 5
      st/ed/cnt: 6/9/8 | 7
      st/ed/cnt: 6/7/8 | 6
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
class Solution {
public:
int findKthNumber(int m, int n, int k) {
// 对于每一个数字x,他在乘法表中是多大的?
// 那么求解方案就变成了:
// 找到最小的数字,这个数字有k个小于等于他
int st = 1, ed = m*n, mid;
while(st != ed) {
// 小于等于mid的个数为cnt
mid = (st + ed) >> 1;
int cnt = 0;
for(int line = 1; line <= m; ++line) {
cnt += min(mid / line, n);
}

cout << "st/ed/cnt: " << st << "/" << ed << "/" << cnt << " | " << mid << endl;
if(cnt >= k) {
ed = mid;
} else {
st = mid + 1;
}
}
return st;

}
};

0004findMedianSortedArrays 寻找两个正序数组的中位数

1 题目

https://leetcode.cn/problems/median-of-two-sorted-arrays/

2 解题思路

  • 1 解题思路:
    • 1.1 将直接二分找目标数的思路转换一下,找两个数组中第k大的数字
    • 1.2 那么通过比较 A[k/2 - 1]和B[k/2 - 1]这两个数字,我们能发现,在这两个数字左侧(不包含这两个数字)最多有k - 2个,这样最答案一定不在前面的AB段里面,可以去掉A的或者B的[:k/2 - 1]这个部分的数字,然后更新k(k代表着我们还要去掉多少个数字),同时更新stA和stB标记AB的起点
    • 1.3 退出的过程:当 1 = k的时候,意味着从AB当前的起点,仅需要找一个数字即可,那么自然是两个的最小值
    • 1.4 考虑,肯定会出现A或者B的下标溢出情况,那么由于我们知道还有多少个数字要数,那么直接在另一个不溢出的数组里返回结果即可!
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Solution {
public:
int getKthElement(vector<int>& nums1, vector<int>& nums2, int k) {
// cout <<"------------" << endl;
int idx1 = k / 2 - 1;
int m = nums1.size();
int n = nums2.size();
int st1 = 0;
int st2 = 0;

while(true) {
// 当k消耗殆尽,则直接返回结果
// cout << "m/n" << m << " " << n << endl;
if(st1 == m) {
// cout <<"aa" << endl;
return nums2[st2 + k - 1];
}
if(st2 == n) {
return nums1[st1 + k - 1];
}
if(k == 1) {
// cout << st1 << " | " << st2 << endl;
return min(nums1[st1], nums2[st2]);
}

int nextSt1 = min(st1 + k / 2 - 1, m - 1);
int nextSt2 = min(st2 + k / 2 - 1, n - 1);
if(nums1[nextSt1] > nums2[nextSt2]) {
k = k - (nextSt2 + 1 - st2); // 排除了这么些个数字
st2 = nextSt2 + 1;
// cout << "st2: " << st2 << endl;
} else {
k = k - (nextSt1 + 1 - st1);
st1 = nextSt1 + 1;
// cout << "st1: " << st1 << endl;
}
}

return -1;
}


double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int len = nums1.size() + nums2.size();
if(len % 2 == 1) {
return getKthElement(nums1, nums2, len / 2 + 1);
}
return static_cast<double>(getKthElement(nums1, nums2, len / 2 + 1) + getKthElement(nums1, nums2, len / 2)) / 2;

}
};

1 实时渲染里的光线追踪

1.1 为什么不用离线的蒙特卡洛呢?

回顾基于蒙特卡罗中的rayTracing,大致看一下其计算量:
当图像目标设置为768768,然后每个像素sample32次(蒙特卡罗的经典方式),每次光线进入到下一层的概率为0.25,那么平均castray会被计算多少次呢?32 * (10.25 + 2 * 0.125 + 3 0.0625 + …) 大概就是0.7532 = 24次,也就76876824的castRay的计算,花费的时间大概为23min,效果为:

spp32_768x768_possiblity0
很显然这样子的计算速度在实时渲染里是太慢了,而且噪声依然很明显,于是我们考虑新的办法:时间上图像的连续性 + 滤波。

1.2 工业界的hack

注意到光线追踪是连续的两帧之间的光线追踪,那么可以假设当前帧的结果来自上一帧和当前帧的结合,也就是利用的时间上图像的连续性,具体的利用方式见下图:
rtr_denoising&Filter

2 具体的实时光线追踪降噪

2.1 单帧滤波实现

这里给出gauss滤波的实现: 参考提交:Feature: 添加高斯模糊,sigma越大,中心权重越大,kernel大小和sigma要相互关联

1
2
3
4
5
6
7
8
// -------------------------------- 算法本身
For each pixel i
sum_of_weights = sum_of_weighted_values = 0.0
For each pixel j around i
Calculate the weight w_ij = G(|i - j|, sigma)
sum_of_weighted_values += w_ij * C^{input}[j]
sum_of_weights += w_ij
C^{output}[I] = sum_of_weighted_values / sum_of_weights

简单说明一下gauss滤波的效果,其实主要就是mean和deviation,下面的mean就是二维向量i :(x,y),deviation就是sigmaCoord,考虑到3sigma时高斯值降为0,那么为了让高斯模糊的效果不那么明显,我们将sigma = kernel / 3 / 2,将高斯的能量集中在i像素附近

1
2
3
4
return pow(
2.718281,
-(sqrt(Dot(Abs(i - j), Abs(i - j))) / (2 * sigmaCoord * sigmaCoord)) \
);

很显然,kernel越大,越模糊,这个就不展示了,主要看一下sigma的影响:sigma越小,肯定模糊就越少:kenerl都是5,从
| sig: 5/6 | sig: 5/3 |
| - | - |
| sigmaCoord5div6kernel5 | GausssigmaCoord5div3kernel5 |

给出双边滤波函数为:
$$
J(i,j)
=\exp
\left(
-{\frac {| i-j |^{2}}{2\sigma_p ^{2}}}
-{\frac {| \tilde{C}[i]-\tilde{C}[j] |^{2}}{2\sigma_c ^{2}}}
-{\frac {D_{normal}(i, j)^{2}}{2\sigma_n ^{2}}}
-{\frac {D_{plane}(i, j)^{2}}{2\sigma_d ^{2}}}
\right)
$$
其中的i,j表示两个像素位置,

  • 1 $\sigma_p$,表示根据位置做高斯模糊的高斯分布半径,我取的是kernelSize / 3.
  • 2 $\sigma_c$,表示两个位置的颜色的差距,太大了就不参与模糊,那么颜色范围则取0.6吧,对应的是下面gif中灯具的周围变清晰的变化
  • 3 $\sigma_n$,代表衡量两个点的法向的区别,其中$D_{normal}(i,j) = arccos(Normal[i] \cdot Normal[j])$,很显然,当两个法向接近的时候,就会使得该项为0,也就是不会被排除掉,当然如果两个反向垂直,那么会被忽略,对应的是下面gif中墙面连接处的变化
  • 4 $\sigma_d$,代表衡量两个像素所在平面之间的差距,$D_{plane}(i,j) = Normal[i] \cdot (\frac {Position[i] - Position[j]}{| Position[i] - Position[j] |})$,意思是考虑两个空间中相隔较远的平面,就不去贡献!这种提供了一种比只是简单计算两个深度的差值更好的指标, 假设场景的视线和墙面平行,那么相邻像素的深度会变化距离,导致一个kernel内本应该贡献的像素点没有贡献,这也就是判断,如果两个平行平面的距离超过了3*sigmaPlane,则不会贡献

这里给出滤波后的结果(四种变化分别代表着从1到4增量累加的效果):
boxJointFilter

实现代码如下:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
float dobuleJointFilter(
Float3 pixelPos1,
Float3 pixelPos2,

float sigmaPlane,
float sigmaColor,
float sigmaNormal,
float sigmaCoord,
const FrameInfo& frameinfo) {
auto deltaPos = pixelPos1 - pixelPos2;

// 当颜色差距过大,不去贡献gauss模糊
auto color1 = frameinfo.m_beauty(pixelPos1.x, pixelPos1.y);
auto color2 = frameinfo.m_beauty(pixelPos2.x, pixelPos2.y);
auto deltaColor = color1 - color2;

// 平面的法向差距过大,不去贡献:eg: normal1 * normal2 = (0,0,0)
// 那么就会使得deltaNormal = 1,自然高斯就不去贡献,因为3*sigmaNormal才等于0.3
auto normal1 = frameinfo.m_normal(pixelPos1.x, pixelPos1.y);
auto normal2 = frameinfo.m_normal(pixelPos2.x, pixelPos2.y);
auto deltaNormal = SafeAcos(Dot(normal1, normal2));

// 意思是考虑两个空间中相隔较远的平面,就不去贡献!这种提供了一种比只是简单计算两个深度的差值更好的指标,
// 假设场景的视线和墙面平行,那么相邻像素的深度会变化距离,导致一个kernel内本应该贡献
// 的像素点没有贡献,这也就是判断,如果两个平行平面的距离超过了3*sigmaPlane,则不会贡献
auto pos3D1 = frameinfo.m_position(pixelPos1.x, pixelPos1.y);
auto pos3D2 = frameinfo.m_position(pixelPos2.x, pixelPos2.y);
auto deltaPos3D = pos3D1 - pos3D2;
Float3 deltaPlane(0, 0, 0);
if (0 != Dot(deltaPos3D, deltaPos3D)) {
deltaPlane = normal1 * (deltaPos3D / sqrt(Dot(deltaPos3D, deltaPos3D)));
}
return pow(
2.718281,
-(Dot(deltaPos, deltaPos) / (2 * sigmaCoord * sigmaCoord)) \
-(Dot(deltaColor, deltaColor) / (2 * sigmaColor * sigmaColor)) \
-(Dot(deltaNormal, deltaNormal) / (2 * sigmaNormal * sigmaNormal)) \
-(Dot(deltaPlane, deltaPlane) / (2 * sigmaPlane * sigmaPlane))
);
}

Buffer2D<Float3> Denoiser::Filter(const FrameInfo &frameInfo) {
int height = frameInfo.m_beauty.m_height;
int width = frameInfo.m_beauty.m_width;
Buffer2D<Float3> filteredImage = CreateBuffer2D<Float3>(width, height);
int kernelRadius = 7;
m_sigmaCoord = static_cast<float>(kernelRadius) / 3.0;
m_sigmaPlane = 0.35;
#pragma omp parallel for
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// TODO: Joint bilateral filter
double weightsSum = 0;
double c1ValueSum = 0;
double c2ValueSum = 0;
double c3ValueSum = 0;
for (int newX = x - kernelRadius; newX <= x + kernelRadius; ++newX) {
for (int newY = y - kernelRadius; newY <= y + kernelRadius; ++newY) {
int cx = std::min(std::max(0, newX), width);
int cy = std::min(std::max(0, newY), height);
float weight = dobuleJointFilter(
Float3(x, y, 0), Float3(cx, cy, 0),
m_sigmaPlane, m_sigmaColor, m_sigmaNormal, m_sigmaCoord,
frameInfo
);

// std::cout << "pixel pos: " << cx << ", " << cy << " | " << frameInfo.m_beauty(cx, cy).x << " \ "
// << frameInfo.m_beauty(cx, cy).y << " \ "
// << frameInfo.m_beauty(cx, cy).z << std::endl;

weightsSum += weight;
c1ValueSum += weight * frameInfo.m_beauty(cx, cy).x;
c2ValueSum += weight * frameInfo.m_beauty(cx, cy).y;
c3ValueSum += weight * frameInfo.m_beauty(cx, cy).z;
}
}
c1ValueSum /= weightsSum;
c2ValueSum /= weightsSum;
c3ValueSum /= weightsSum;
filteredImage(x, y) = Float3(c1ValueSum, c2ValueSum, c3ValueSum);
}
}
return filteredImage;
}


2.2 投影上一帧结果

计算当前帧每个像素在上一帧的对应点,并将上一帧的结果投影到当前帧。

2.1 MVP转化和viewPort转化:

  • 1 首先是MVP: M,物体从世界坐标000旋转平移缩放,V,将相机放在合适的位置的转换:比如说放到000,P,投影转换,将物体从世界坐标转到边长为-1到1的一个cur里面,可以做视锥剔除,一般是跟你的透视矩阵定义的有关,比如 near,far,bottom,up,fov几个参数,做完透视投影以后,我们认为物体的坐标都在NDC里面了
    PerspectiveProjections
  • 2 viewPort投影(视口投影)
  • 2.0 主要就是将NDC空间里的值,切换成像素坐标
  • 2.1 将物体坐标放缩到图片大小: Scale the (-1,-1) to (+1,+1) viewing window to the image’s width and height.
  • 2.2 将near平面(这也是成像平面)左下角的坐标从(-w/2,-h/2)移动到0,0: Offset the lower-left corner at (-width/2,-height/2) to the image’s origin.
    Viewports

2.2 从上一帧获取结果

主要思路就是:

  • 1 计算当前帧每个像素在上一帧的对应点,并将上一帧的结果投影到当前帧。

那么当前帧的一个物体的世界坐标如何投影到上一帧对应的像素坐标呢?
$Screen_{i-1} = P_{i-1}V_{i-1}M_{i}M_{i}^{-1}World_i$
看下面从matItemWorldPos转换到上一帧的屏幕坐标经历的过程:

1
2
3
4
5
6
7
// 注意lastScreePos算出来的是,[x,y,depth,w],没有经过归一化的,需要自己归一化啊
auto lastScreenPos = preWorldToScreen * // 物体该点的屏幕坐标
preItemToWord * // 物体上一帧这个点的世界坐标
Inverse(itemToWord) * // 物体上该点的本地坐标
matItemWorldPos; // 物体上这个像素对应的点的世界坐标
lastX = lastScreenPos.m[0][3] / lastScreenPos.m[3][3];
lastY = lastScreenPos.m[1][3] / lastScreenPos.m[3][3];
  • 2 那么用几个简单标志判断上一帧是否有效: 算出来的屏幕坐标对应的点,不一定是当前物体,想象一下,当前帧的上一帧,物体的这个点还被别的东西遮挡,但是当前帧则移动出来了
    • 2.1 上一帧的点是否在屏幕内部(用蓝色表示)
    • 2.2 上一帧的点他们是否为同一个物体(用绿色表示)
    • 2.3 上一帧的点是否对应的是物体(不是的话用红色表示)
  • 3 只有同时满足2的3个要求,我们认为这是有用的,否则直接用当前帧对应的像素点即可
  • 4 并将投影是否合法保存在 m_valid 以供我们在累积多帧信息时使用,因为存在macc_color是上一帧的结果,我们用m_valid(x,y)记录上一帧的像素在当前帧是否任然可以使用,不能的话就用当前帧滤波以后的结果就行

这里给出backProjection的代码:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// 主要目的:1. 得到当前帧的像素x,y在上一帧中是否可用,存在valid(x,y)上,m_accColor存的上一帧的信息就不要动了
// 主要目的:那些不valid点,就不会进入accumulateColor的过程,转而直接使用当前帧滤波以后的结果
void Denoiser::Reprojection(const FrameInfo &frameInfo) {
int height = m_accColor.m_height;
int width = m_accColor.m_width;
Matrix4x4 preWorldToScreen =
m_preFrameInfo.m_matrix[m_preFrameInfo.m_matrix.size() - 1];
Matrix4x4 preWorldToCamera =
m_preFrameInfo.m_matrix[m_preFrameInfo.m_matrix.size() - 2];
#pragma omp parallel for
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// TODO: Reproject, fill into m_accColor
m_valid(x, y) = false; // 这个valid标志,会被时间上的累计中的clamp干掉
// m_misc(x, y) = Float3(0.f);
int lastX, lastY;
if (-1 == frameInfo.m_id(x, y)) { // 不是物体
m_misc(x, y) = frameInfo.m_beauty(x, y);
// m_misc(x, y) = Float3(2, 0, 0);
}
else { // 是物体
int itemId = frameInfo.m_id(x, y);
auto& itemToWord = frameInfo.m_matrix[itemId];
auto& itemWorldPos = frameInfo.m_position(x, y);
Matrix4x4 matItemWorldPos;
memset(matItemWorldPos.m, 0, sizeof(float) * 16);
matItemWorldPos.m[0][3] = itemWorldPos.x;
matItemWorldPos.m[1][3] = itemWorldPos.y;
matItemWorldPos.m[2][3] = itemWorldPos.z;
matItemWorldPos.m[3][3] = 1;

// // 检测存的坐标是不是世界坐标还是相机坐标
// auto curWorldToScreen = frameInfo.m_matrix[m_preFrameInfo.m_matrix.size() - 1];
// auto curScreenPos = curWorldToScreen * matItemWorldPos;
// auto preCamToScreen = preWorldToScreen * Inverse(preWorldToCamera);

auto& preItemToWord = m_preFrameInfo.m_matrix[itemId];

// 算出来的屏幕坐标对应的点,不一定是当前物体,想象一下,当前帧的上一帧,物体的这个
// 点还被别的东西遮挡,但是当前帧则移动出来了
// 注意lastScreePos算出来的是,[x,y,depth,w],没有经过归一化的,需要自己归一化啊
auto lastScreenPos = preWorldToScreen * // 物体该点的屏幕坐标
preItemToWord * // 物体上一帧这个点的世界坐标
Inverse(itemToWord) * // 物体上该点的本地坐标
matItemWorldPos; // 物体上这个像素对应的点的世界坐标

lastX = lastScreenPos.m[0][3] / lastScreenPos.m[3][3];
lastY = lastScreenPos.m[1][3] / lastScreenPos.m[3][3];
if (0 <= lastX && lastX < width && 0 <= lastY && lastY < height) {
if (m_preFrameInfo.m_id(lastX, lastY) == itemId) {
// 将上一帧的结果能否可以投影到当前帧记作m_valid(x,y)
// 然后将上一帧的颜色投影到当前帧,使用m_misc作为中间变量
// 最后还是存在m_accColor
// m_misc(x, y) = m_preFrameInfo.m_beauty(lastX, lastY);
m_misc(x, y) = m_accColor(lastX, lastY);
m_valid(x, y) = true;
}
else {
m_misc(x, y) = frameInfo.m_beauty(x, y);
// 方便调试:
// m_misc(x, y) = Float3(0, 2, 0);
}
}
else {
m_misc(x, y) = frameInfo.m_beauty(x, y);
// m_misc(x, y) = Float3(0, 0, 2);
}
}
}
}
std::swap(m_misc, m_accColor);
}

  • 4 结果展示:
    这是带有debug信息的展示:
    boxReprojection

pinkRoomBackprojection5

2.3 累计多帧信息

在这个部分,你需要将已经降噪的当前帧图像 $\overline{C_{i}}$,与已经降噪的上一帧图像
$\overline{C_{i-1}}$ 进行结合,公式如下:
$$
\overline{C_{i-1}} \leftarrow \alpha\overline{C_{i}} + (1 - \alpha) Clamp(\overline{C_{i-1}})
$$
其实比较好理解:

  • 1 当上一帧的像素在当前帧对应的物体不是同一个物体,则阿尔法等于1
  • 2 对于Clamp部分,我们首先需要计算 Ci 在 7×7 的邻域内的均值 µ 和方差 σ,
    然后我们将上一帧的颜色 Ci−1Clamp 在 (µ − kσ, µ + kσ) 范围内。

最终结果展示

boxFinalRes

针对pinkroom场景,需要改一下滤波器的color部分的deviation也就是$sigma_{color}$,最关键的是需要明白这个sigma如何取因为对于pinkroom,可以看到它颜色的变化很大:(上面是box,下面是pinkRoom)
box

pinkRoom

所以我们调整simga到: 3*sigma <= meanDis 约等于:6,所以sigma我们取4.5左右就好

Ref:

0 主要对games101中的离线渲染做一个归纳

1 Whitted-Style Ray Tracing 只考虑折射 反射(可以多次反射、折射)

Whitted-style光线追踪算法是由Turner Whitted在1980年提出的,用于解决复杂曲面的反射折射效果。
最终每个像素的颜色(相比于blin phong 模型多了反射和折射):

fragColor = 直接光照颜色 + 反射光带来的颜色 + 折射光带来的颜色。

核心思想:利用了光路是可逆的,由浅入深主要有以下两个步骤:

1.1 step1: rayCasting

如下图:从相机作为原点出发,连接像素发射一条光线ray,假设碰撞到物体就结束(不反射,不折射3),那么直接光源照射到碰撞到物体的点,然后逆着ray到对应的像素,就得到了对应像素的颜色,这个过程我们显而易见只考虑了从物体直接射到像素的ray,并没有考虑折射和反射,所以这个第一步相当于blin phong模型
https://raw.githubusercontent.com/xychen5/blogImgs/main/imgs/Whitted-StyleRayTracing_rayCasting.5jwjx8mj4ak0.webp

1.2 step2: 考虑反射折射的rayCasting

从图中清晰看到,ray照射到球体,发生了反射,折射,那么可以看到,这是折射2次,反射1次的图,那么ray对应的像素的颜色贡献有:最左边圆上的点的直射,来自三角形的点的反射,来自正方形的折射两次以后的颜色,很显然这个rayCasting过程会随着折射与反射递归,具体递归(也就是追踪过程的一些细节):

  • 1 设置最大次数,以停止
  • 2 光反射折射都是有能量损耗的,比如折射以后进入下一次递归乘以一个系数
  • 3 若没碰到物体,返回背景色即可
    https://raw.githubusercontent.com/xychen5/blogImgs/main/imgs/Whitted-StyleRayTracing.1xxchqgzstxc.webp

看看具体代码实现:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
// ------------------------- 设置材质
auto sph2 = std::make_unique<Sphere>(Vector3f(0.5, -0.5, -8), 1.5);
sph2->ior = 1.5;
sph2->materialType = REFLECTION_AND_REFRACTION;
scene.Add(std::move(mesh));
scene.Add(std::make_unique<Light>(Vector3f(-20, 70, 20), 0.5));
scene.Add(std::make_unique<Light>(Vector3f(30, 50, -12), 0.5));

// ------------------------- 调用处
void Renderer::Render(const Scene& scene)
{
std::vector<Vector3f> framebuffer(scene.width * scene.height);
std::vector<Vector3f> framebuffer2(scene.width * scene.height);

float scale = std::tan(deg2rad(scene.fov * 0.5f));
float imageAspectRatio = scene.width / (float)scene.height;

// Use this variable as the eye position to start your rays.
Vector3f eye_pos(0);
int m = 0;
for (int j = 0; j < scene.height; ++j)
{
for (int i = 0; i < scene.width; ++i)
{
// generate primary ray direction
float x;
float y;
// TODO: Find the x and y positions of the current pixel to get the direction
// vector that passes through it.
// Also, don't forget to multiply both of them with the variable *scale*, and
// x (horizontal) variable with the *imageAspectRatio*
// To NDC space
x = (float)i / scene.width - 0.5;
y = (float)(scene.height - j) / scene.height - 0.5;
// To world space
x *= scale * imageAspectRatio;
y *= scale;

Vector3f dir = Vector3f(x, y, -1); // Don't forget to normalize this direction!
dir = normalize(dir);
framebuffer[m++] = castRay(eye_pos, dir, scene, 0);
}
UpdateProgress(j / (float)scene.height);
}

// save framebuffer to file
FILE* fp = fopen("binary.ppm", "wb");
(void)fprintf(fp, "P6\n%d %d\n255\n", scene.width, scene.height);
for (auto i = 0; i < scene.height * scene.width; ++i) {
static unsigned char color[3];
color[0] = (char)(255 * clamp(0, 1, framebuffer[i].x));
color[1] = (char)(255 * clamp(0, 1, framebuffer[i].y));
color[2] = (char)(255 * clamp(0, 1, framebuffer[i].z));
fwrite(color, 1, 3, fp);
}
fclose(fp);
}



// ------------------------- castRay
Vector3f castRay(
const Vector3f &orig, const Vector3f &dir, const Scene& scene,
int depth)
{
if (depth > scene.maxDepth) {
return Vector3f(0.0,0.0,0.0);
}

Vector3f hitColor = scene.backgroundColor;
if (auto payload = trace(orig, dir, scene.get_objects()); payload)
{
Vector3f hitPoint = orig + dir * payload->tNear;
Vector3f N; // normal
Vector2f st; // st coordinates
payload->hit_obj->getSurfaceProperties(hitPoint, dir, payload->index, payload->uv, N, st);
switch (payload->hit_obj->materialType) {
case REFLECTION_AND_REFRACTION:
{
Vector3f reflectionDirection = normalize(reflect(dir, N));
Vector3f refractionDirection = normalize(refract(dir, N, payload->hit_obj->ior));
Vector3f reflectionRayOrig = (dotProduct(reflectionDirection, N) < 0) ?
hitPoint - N * scene.epsilon :
hitPoint + N * scene.epsilon;
Vector3f refractionRayOrig = (dotProduct(refractionDirection, N) < 0) ?
hitPoint - N * scene.epsilon :
hitPoint + N * scene.epsilon;
Vector3f reflectionColor = castRay(reflectionRayOrig, reflectionDirection, scene, depth + 1);
Vector3f refractionColor = castRay(refractionRayOrig, refractionDirection, scene, depth + 1);
float kr = fresnel(dir, N, payload->hit_obj->ior);
hitColor = reflectionColor * kr + refractionColor * (1 - kr);
break;
}
case REFLECTION:
{
float kr = fresnel(dir, N, payload->hit_obj->ior);
Vector3f reflectionDirection = reflect(dir, N);
Vector3f reflectionRayOrig = (dotProduct(reflectionDirection, N) < 0) ?
hitPoint + N * scene.epsilon :
hitPoint - N * scene.epsilon;
hitColor = castRay(reflectionRayOrig, reflectionDirection, scene, depth + 1) * kr;
break;
}
default:
{
// [comment]
// We use the Phong illumation model int the default case. The phong model
// is composed of a diffuse and a specular reflection component.
// [/comment]
Vector3f lightAmt = 0, specularColor = 0;
Vector3f shadowPointOrig = (dotProduct(dir, N) < 0) ?
hitPoint + N * scene.epsilon :
hitPoint - N * scene.epsilon;
// [comment]
// Loop over all lights in the scene and sum their contribution up
// We also apply the lambert cosine law
// [/comment]
for (auto& light : scene.get_lights()) {
Vector3f lightDir = light->position - hitPoint;
// square of the distance between hitPoint and the light
float lightDistance2 = dotProduct(lightDir, lightDir);
lightDir = normalize(lightDir);
float LdotN = std::max(0.f, dotProduct(lightDir, N));
// is the point in shadow, and is the nearest occluding object closer to the object than the light itself?
auto shadow_res = trace(shadowPointOrig, lightDir, scene.get_objects());
bool inShadow = shadow_res && (shadow_res->tNear * shadow_res->tNear < lightDistance2);

lightAmt += inShadow ? 0 : light->intensity * LdotN;
Vector3f reflectionDirection = reflect(-lightDir, N);

specularColor += powf(std::max(0.f, -dotProduct(reflectionDirection, dir)),
payload->hit_obj->specularExponent) * light->intensity;
}

hitColor = lightAmt * payload->hit_obj->evalDiffuseColor(st) * payload->hit_obj->Kd + specularColor * payload->hit_obj->Ks;
break;
}
}
}

return hitColor;
}

2 蒙特卡洛rayTracing

2.1 回看1的whitted光线追中的问题

很显然它只考虑了折射与反射,漫反射呢?并没有考虑
漫反射是一个很复杂的问题,那么思考一下,漫反射,不就是在不光滑表面的反射版本吗?基于1如何改进一下反射呢?

1中,我们可以知道从一个像素中仅有一条ray,然后反射是镜面反射,那么现在我们考虑在反射点,将原来的一条反射,发散成为1000条,然后均匀的分布在反射点能够反射的各个方向(对于这些采样的反射反向,也就是这些个立体角),把这些反射方向上的光线的颜色进行一个加权求和(为什么不是平均求和?因为反射肯定和你入射方向相关),基本上就是这个思路。

那么考虑一下几个问题:

  • 1 什么时候停止?
    • 1.1 较为容易,采用轮盘赌思想:我们考虑每一条sample的光线继续追踪下去的概率是0.95,那么随着追踪次数变多,它越来越不可能继续追踪下去,比如20次以后,它继续下去的概率就变成了:0.95 ** 20 = 0.35
  • 2 采样的光线不会被浪费吗(很多都打不到灯)?
    • 打不到灯,那就意味着没有光从那里发出,必然是黑色的啊,那么采样的立体角就不要覆盖所有可能的漫反射方向,而是改为只对那些能够照到光源的光线进行采样

这里给一个算法伪代码:
https://raw.githubusercontent.com/xychen5/blogImgs/main/imgs/monteKalor.25382mdjlvc0.webp

接着看一下具体的实现:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 对每个像素发射ray,写到framBuffer,然后最后写出文件即可
for (uint32_t j = 0; j < scene.height; ++j) {
for (uint32_t i = 0; i < scene.width; ++i) {
// generate primary ray direction
float x = (2 * (i + 0.5) / (float)scene.width - 1) *
imageAspectRatio * scale;
float y = (1 - 2 * (j + 0.5) / (float)scene.height) * scale;

Vector3f dir = normalize(Vector3f(-x, y, 1));
thread_local Vector3f color = Vector3f(0.0);
for (int k = 0; k < spp; k++){ // 每个点sample 8 次
framebuffer[m] += scene.castRay(Ray(eye_pos, dir), 0) / spp;
}
m++;
}
UpdateProgress(j/(float)scene.height);
}

// path Tracing的具体步骤:
Vector3f Scene::castRay(const Ray &ray, int depth) const
{
// TO DO Implement Path Tracing Algorithm here
Intersection intersection = intersect(ray);
Vector3f hitcolor = Vector3f(0);

//deal with light source
if(intersection.emit.norm()>0) {
// 碰到光源,这接吧hitColor置为1,则不会进入pathTracing的过程
hitcolor = Vector3f(1);
}
else if(intersection.happened)
{
// 发出的射线能够和物体相交,则进行pathTracing
Vector3f wo = normalize(-ray.direction); // 你的发射的ray的方向
Vector3f p = intersection.coords; // 获取碰撞点的世界坐标
Vector3f N = normalize(intersection.normal); // 获取碰撞点的法向

float pdf_light = 0.0f;
Intersection inter;

/*
void Scene::sampleLight(Intersection &pos, float &pdf) const
{
float emit_area_sum = 0;
for (uint32_t k = 0; k < objects.size(); ++k) {
if (objects[k]->hasEmit()){
emit_area_sum += objects[k]->getArea();
}
} // 获取所有发射光的区域
float p = get_random_float() * emit_area_sum;
emit_area_sum = 0;
for (uint32_t k = 0; k < objects.size(); ++k) {
if (objects[k]->hasEmit()){
emit_area_sum += objects[k]->getArea();
if (p <= emit_area_sum){
objects[k]->Sample(pos, pdf);
break;
}
}
}
}
*/
sampleLight(inter,pdf_light); // 上面注释给出了,是如何sampleLight的
Vector3f x = inter.coords; // 对发光体sample得到的坐标
Vector3f ws = normalize(x-p); // 从碰撞点指向发光体
Vector3f NN = normalize(inter.normal); // 发光体的法向

Vector3f L_dir = Vector3f(0);
//direct light, pdf_light = 1 / A,A是光源面积,这里就是直接对光源面积进行颜色的积分
if((intersect(Ray(p,ws)).coords - x).norm() < 0.01)
{
L_dir = inter.emit * intersection.m->eval(wo,ws,N)*dotProduct(ws,N) * dotProduct(-ws,NN) / (((x-p).norm()* (x-p).norm()) * pdf_light);
}

Vector3f L_indir = Vector3f(0);
float P_RR = get_random_float();
//indirect light
if(P_RR < Scene::RussianRoulette) // 继续追踪的可能性指数级下跌
{
Vector3f wi = intersection.m->sample(wo,N); // wi, 采样的方向,随机带来的均匀
L_indir = castRay(Ray(p,wi),depth) *intersection.m->eval(wi,wo,N) * dotProduct(wi,N) / (intersection.m->pdf(wi,wo,N)*Scene::RussianRoulette); // 对立体角进行积分,pdf函数返回是
}
hitcolor = L_indir + L_dir;
}

// 不能和物体相交的,直接return (0,0,0)
return hitcolor;
}

简单贴结果:
图像目标设置为768768,然后每个像素sample32次(蒙特卡罗的经典方式),每次光线进入到下一层的概率为0.25,那么平均castray会被计算多少次呢?32 * (10.25 + 2 * 0.125 + 3 0.0625 + …) 大概就是0.7532 = 24次,也就76876824的castRay的计算,花费的时间大概为23min,效果为:

spp32_768x768_possiblity0
很显然这样子的计算速度在实时渲染里是太慢了,而且噪声依然很明显,于是我们考虑新的办法

3 Ref

1 BFS

最关键的是:
- bfs本身的特性,一个是按照层数往下遍历,一个是可以决定是否遍历完当前层再往下遍历
- bfs的队列初始化即为bfs的搜索起点,然后注意状态问题,当每个节点带有状态以后,bfs的visited变量就不仅仅是node的编号,应该是node的编号 + “当前状态”,具体参考(0864,0847)
- 注意:遍历当前层,需要把size记录成临时变量,因为curLevel会加入新元素
- 注意:入队后就需要置为visited,否则可能重复入队

bfs的遍历本质上可以看成当前点,到所有后继节点的可能跳转的状态。(0733例题)

bfs的核心代码:

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
35
36
37
38
39
40
41
42
int bfsCheck(vector<vector<int>>& oriBoard) {
queue<vector<vector<int>>> curLevel;
curLevel.push(oriBoard);
vis.insert(toInt(oriBoard));

int depth = 1;
while(!curLevel.empty()) {
int curSize = curLevel.size();
// pop all curLevel and do next level
while(curSize-- > 0) { // 最关键的,一定不要直接写成curLevel.size,因为curLevel会放后面的节点的呜呜呜
auto board = curLevel.front();
curLevel.pop();

auto coord = getPos(board);
int x = coord.first;
int y = coord.second;

for(int mv = 0; mv < 4; ++mv) {
int nextX = x + dx[mv];
int nextY = y + dy[mv];
if(0 <= nextX && nextX < 2 && 0 <= nextY && nextY < 3) {
swap(board[nextX][nextY], board[x][y]);
// cout << "trying : " << x << ", " << y << " to " << nextX << ", " << nextY << " withd d = " << depth <<endl;
// print(board);

int intBoard = toInt(board);

if(0 == vis.count(intBoard)) {
curLevel.push(board);
if(isTar(board)) {
// cout << "final tar: " << endl;
// print(board);
return depth;
}
vis.insert(intBoard);
}
swap(board[x][y], board[nextX][nextY]);
}
}
}
++depth;
}

2 例题

0733slidingPuzzle 滑动到123450谜题

1 题目

https://leetcode.cn/problems/sliding-puzzle/

2 解题思路

  • 1 解题思路:
    • 1.1 为何选用bfs,因为要获得最终的移动步数,那么bfs的层数就是最终的答案
    • 1.2 节点就是当前的棋盘,后继节点为移动后改变的棋盘
    • 1.3 使用位运算加速
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
class Solution {
public:
constexpr static int tar = ((1<<3) | (2<<6) | (3<<9) | (4<<12) | (5<<15));
unordered_set<int> vis;

int dx[4] = {1, 0, -1, 0};
int dy[4] = {0, -1, 0, 1};


bool isTar(vector<vector<int>>& board) {
return tar == toInt(board);
}
int toInt(vector<vector<int>>& board) {
return board[0][0]<<3 | board[0][1]<<6 | board[0][2]<<9 | board[1][0]<<12 | board[1][1]<<15;
}

pair<int, int> getPos(vector<vector<int>>& board) {
for(int i = 0; i < 2; ++i) {
for(int j = 0; j < 3; ++j) {
if(board[i][j] == 0) {
return {i, j};
}
}
}
return {-1, -1};
}

int slidingPuzzle(vector<vector<int>>& board) {
int res = 0;
if(toInt(board) == tar) {
return 0;
}

res = bfsCheck(board);
return res;
}

void swap(int& a, int& b) {
int tmp = a;
a = b;
b = tmp;
}

int bfsCheck(vector<vector<int>>& oriBoard) {
queue<vector<vector<int>>> curLevel;
curLevel.push(oriBoard);
vis.insert(toInt(oriBoard));

int depth = 1;
while(!curLevel.empty()) {
int curSize = curLevel.size();
// pop all curLevel and do next level
while(curSize-- > 0) {
auto board = curLevel.front();
curLevel.pop();

auto coord = getPos(board);
int x = coord.first;
int y = coord.second;

for(int mv = 0; mv < 4; ++mv) {
int nextX = x + dx[mv];
int nextY = y + dy[mv];
if(0 <= nextX && nextX < 2 && 0 <= nextY && nextY < 3) {
swap(board[nextX][nextY], board[x][y]);
// cout << "trying : " << x << ", " << y << " to " << nextX << ", " << nextY << " withd d = " << depth <<endl;
// print(board);

int intBoard = toInt(board);

if(0 == vis.count(intBoard)) {
curLevel.push(board);
if(isTar(board)) {
// cout << "final tar: " << endl;
// print(board);
return depth;
}
vis.insert(intBoard);
}
swap(board[x][y], board[nextX][nextY]);
}
}
}
++depth;
}
return -1;

}

void print(vector<vector<int>>& board) {
for(int i = 0; i < 2; ++i) {
for(int j = 0; j < 3; ++j) {
cout << board[i][j] << " ";
}cout << " | ";
}
cout << "----------\n";
}

};

0675golfCutTree 高尔夫砍树

1 题目

https://leetcode.cn/problems/cut-off-trees-for-golf-event/

2 解题思路

  • 1 解题思路:
    • 1.1 使用优先队列将所有树从小到高排序,存为trees
    • 1.2 每次从trees中取出一个树,对其进行bfs直到找到trees的下一个目标
  • 2 bfs思路:
    • 2.1 queue初始化为起点
    • 2.2 对于queue中所有元素,加入当前queue的所有后继,加入以后就把元素置为visited
    • 2.3 进入下一个level
  • 3 为什么加入的过程中就要吧元素置为vis?因为:假设1层有a,b, 2层有c,c为ab的邻居,那么遍历第1层的时候,对于ab的后继,到a,加入c,若不置为vis,则b会再加入一遍,导致bfs很慢!
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
class Solution {
public:
int m;
int n;

int dx[4] = {1, 0, -1, 0};
int dy[4] = {0, -1, 0, 1};
int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

int cutOffTree(vector<vector<int>>& forest) {
// get all trees
m = forest.size();
n = forest[0].size();

auto cmp = [](const pair<pair<int, int>, int>& a, const pair<pair<int, int>, int>& b) {
return a.second > b.second;
};
priority_queue<pair<pair<int, int>, int>, vector<pair<pair<int, int>, int>>, decltype(cmp)>trees (cmp);

for(int i = 0; i < m; ++i) {
for(int j = 0; j < n; ++j) {
if(forest[i][j] > 1) {
trees.push({{i, j}, forest[i][j]});
}
}
}


int curX = 0, curY = 0;
int tarX = -1, tarY = -1;
int res = 0;
while(!trees.empty()) {
auto tree = trees.top();
trees.pop();

tarX = tree.first.first;
tarY = tree.first.second;

// judge if we can walk to x, y
// cout << "from/to: " << curX << "," << curY << " -> " << tarX << "," << tarY << endl;
if(!tryWalk(forest, curX, curY, tarX, tarY, res)) {
return -1;
} else {
// cut tarX, tarY
forest[tarX][tarY] = 1;
curX = tarX;
curY = tarY;
}
// auto step = bfs(forest, curX, curY, tarX, tarY);
// if(-1 == step) {
// return -1;
// } else {
// // cut tarX, tarY
// forest[tarX][tarY] = 1;
// res+= step;
// curX = tarX;
// curY = tarY;
// }
}

return res;
}

int bfs(vector<vector<int>>& forest, int sx, int sy, int tx, int ty) {
if (sx == tx && sy == ty) {
return 0;
}

int row = forest.size();
int col = forest[0].size();
int step = 0;
queue<pair<int, int>> qu;
vector<vector<bool>> visited(row, vector<bool>(col, false));
qu.emplace(sx, sy);
visited[sx][sy] = true;
while (!qu.empty()) {
step++;
int sz = qu.size();
for (int i = 0; i < sz; ++i) {
auto [cx, cy] = qu.front();
qu.pop();
for (int j = 0; j < 4; ++j) {
int nx = cx + dirs[j][0];
int ny = cy + dirs[j][1];
if (nx >= 0 && nx < row && ny >= 0 && ny < col) {
if (!visited[nx][ny] && forest[nx][ny] > 0) {
if (nx == tx && ny == ty) {
return step;
}
qu.emplace(nx, ny);
visited[nx][ny] = true;
}
}
}
}
}
return -1;
}


// using bfs from <curX, curY> to <tarX, tarY>, the depth of bfs should be the distance
bool tryWalk(vector<vector<int>>& forest, int curX, int curY, int tarX, int tarY, int& res) {
// bfs
queue<pair<int, int>> curLevel;
vector<vector<int>> vis(m, vector<int>(n, false));

curLevel.push({curX, curY});
vis[curX][curY] = true;

int depth = 0;
while(!curLevel.empty()) {
// queue<pair<int, int>> nextLevel;

// while(!curLevel.empty()) {
int curLevelSize = curLevel.size();
while(curLevelSize-- > 0) {
auto curNode = curLevel.front();

curLevel.pop();
if(curNode == pair<int, int>{tarX, tarY}) {
// update res
res += depth;
return true;
}
for(int mv = 0; mv < 4; ++mv) {
int nextX = curNode.first + dx[mv];
int nextY = curNode.second + dy[mv];

if(0 <= nextX && nextX < m && 0 <= nextY && nextY < n && 0 != forest[nextX][nextY] && !vis[nextX][nextY]) {
curLevel.push({nextX, nextY});
vis[nextX][nextY] = true;
// cout << "nextNode: " << curNode.first << "," << curNode.second << endl;
}
}
}
// curLevel = std::move(nextLevel);
++depth;
}
return false;
}
};

0847allTravelShortestPath 访问所有节点最短路径

1 题目

https://leetcode.cn/problems/shortest-path-visiting-all-nodes/

2 解题思路

  • 1 解题思路:
    • 1.1 使用三元组 <u, mask, dist>表示,当前节点u的mask的搜索情况对应的搜索距离dist,调用bfs即可,但是对于下一个v,我们需要检测: v节点的1 << v | mask这种访问情况是否被访问过
    • 1.2 如何考虑这个方法?
      • 1.2.1 初始化的时候所有节点都加入队里(认为可以从每个节点出发)
      • 1.2.2 bfs的具体过程中,退出条件就是 队首的mask是否标记了所有节点都被访问
    • 1.3 这个方法为什么可以?
      • 一句话:这个方法:利用bfs,按照路径长度从小到大遍历了所有的可能路径(队列初始化有所有的顶点就是这个意思),比如初始化,就是说将长度为0的所有可能路径遍历完成,然后下一层bfs会将所有长度为1的可能路径遍历完成,这个路径的记录方式为mask以及mask对应的终点u,那么由于路径长度是从小到大去遍历的,那么必然保证最终答案是最短路径,
      • 假设用dfs做,那么dfs要考虑所有路径可能,然后去比较,那么会出现枚举的情况,然鹅bfs不会,因为省去所有长度比最短路径大的路径
  • 2 总结一下:最关键的有两点
    • 2.1 利用bfs能够将遍历路径的长度从小到大遍历的特性
    • 2.2 使用<到达点,已经遍历的点,目前长度>来记录所有的遍历情况这个trick很聪明
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
// 这里看一个具体 
[[1,2,3],[0],[0],[0]]

// 具体的遍历顺序为:
// 遍历到达的节点 目前遍历的节点 遍历路径的长度
0 00000001 0
1 00000010 0
2 00000100 0
3 00001000 0
1 00000011 1
2 00000101 1
3 00001001 1
0 00000011 1
0 00000101 1
0 00001001 1
2 00000111 2
3 00001011 2
1 00000111 2
3 00001101 2
1 00001011 2
2 00001101 2
0 00000111 3
0 00001011 3
0 00001101 3
3 00001111 4
3 00001111 4


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
35
class Solution {
public:
int shortestPathLength(vector<vector<int>>& graph) {
queue<tuple<int, int, int>> curLevel;
int n = graph.size();
vector<vector<bool>> seen(n, vector<bool>(1 << n));

for(int i = 0; i < n; ++i) {
curLevel.emplace(i, 1 << i, 0);
seen[i][1 << i] = true;
}

while(!curLevel.empty()) {
auto [node, mask, dist] = curLevel.front();
// printf("%d %x", node, mask);
// cout << dist << endl;
curLevel.pop();

// << not priority to -
if(mask == (1 << n) - 1) {
return dist;
}

for(auto neighbor : graph[node]) {
int newMask = mask | 1 << neighbor;
if(!seen[neighbor][newMask]) {
curLevel.emplace(neighbor, newMask, dist + 1);
seen[neighbor][newMask] = true;
}
}
}

return -1;
}
};

0864getAllKeys 获取所有钥匙的最短路径

1 题目

https://leetcode.cn/problems/shortest-path-to-get-all-keys/submissions/

2 解题思路

  • 1 解题思路:
    • 1.1 首先考虑使用bfs,因为从起点到终点,bfs的遍历深度就等于最短路径,那么有一个问题对于:[“@a.”,”bAB”]这样的地图如何知道经过a和b的最短路呢?
    • 1.2 也就是这个bfs会走”回头路”,但是又不是完全的回头路,因为走路的人的状态发生了变化,也就是手里多了钥匙,也就是bfs的变种:带状态(压缩)的bfs
    • 1.3 那么就很容易想到,原来最普通的bfs判断是否走过就是只用了位置xy,那么现在我们多增加一个信息,也就是拥有的钥匙,那么该点没有走过变成了什么呢?那就是:该点位置没有走过,或者当前的拥有钥匙的状态在该点没有出现过
    • 1.4 有了1.3我们就很容易知道,用什么数据结构去存顶点是否被访问啦: map<pair<int, int>, set> seenKey; 左边是该点的位置,右边是该点所经历过的所有钥匙的集合
    • 1.5 还需要考虑如何记录路径长度:map<pair<pair<int, int>, int>, int> dis; 很显然,左侧是<xy,key>,右侧代表了xy在key情况下的路径长度
    • 1.6 考虑一个具体[“@a.”,”bAB”]的例子即可:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      cur: 0,0 -> 000000
      next: 0,1 charis a-> 000001
      next: 1,0 charis b-> 000010
      cur: 0,1 -> 000001
      next: 0,2 charis .-> 000001
      next: 1,1 charis A-> 000001
      next: 0,0 charis @-> 000001
      cur: 1,0 -> 000010
      next: 0,0 charis @-> 000010
      cur: 0,2 -> 000001
      cur: 1,1 -> 000001
      next: 1,0 charis b-> 000011
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
class Solution {
public:

int m, n;
int dx[4] = {-1, 0, 1, 0};
int dy[4] = {0, 1, 0, -1};

int shortestPathAllKeys(vector<string>& grid) {
m = grid.size();
n = grid[0].size();
int i = 0;
int j = 0;
int keyNum = 0;
int stX = -1, stY = -1;
for(auto& s : grid) {
j = 0;
for(auto& c : s) {
if('a' <= c && c <= 'f') {
++keyNum;
}
if('@' == c) {
stX = i;
stY = j;
}
++j;
}
++i;
}

int tarKey = 0;
for(int i = 0; i < keyNum; ++i) {
tarKey = tarKey | (1 << i);
};

// cout << "tarKey is : " << bitset<8>(tarKey) << " | st: " << stX << " " << stY << endl;
return bfs(grid, stX, stY, tarKey);
}

bool canWalk(vector<string>& grid, int x, int y, int keyInfo) {
if('A' <= grid[x][y] && grid[x][y] <= 'F') {
int keyNum = grid[x][y] - 'A';
return ((keyInfo >> keyNum) & 1) != 0;
}
if('#' == grid[x][y]) {
return false;
}

return true;
}

int bfs(vector<string>& grid, int stX, int stY, int tarKey) {
queue<pair<pair<int, int>, int>> curLevel; // xy, key
curLevel.push({{stX, stY}, 0});
map<pair<pair<int, int>, int>, int> dis; // xy, key -> pathLen
map<pair<int, int>, set<int>> seenKey; // xy -> key
dis[{{stX, stY}, 0}] = 0;
seenKey[{stX, stY}] = {0};

int res = 0;

while(!curLevel.empty()) {
int curSize = curLevel.size();
while(curSize-- > 0) {
auto curNode = curLevel.front();
int curDis = dis[curNode];
int x = curNode.first.first;
int y = curNode.first.second;
int curKey = curNode.second;

curLevel.pop();
// cout << "cur: " << x << "," << y << " -> " << bitset<6>(curKey) << endl;
for(int mv = 0; mv < 4; ++mv) {
int nextX = x + dx[mv];
int nextY = y + dy[mv];
pair<int, int> nextNode = {nextX, nextY};
// nextNode not explored or has new keys
if(0 <= nextX && nextX < m && 0 <= nextY && nextY < n && \
canWalk(grid, nextX, nextY, curKey) && \
(0 == seenKey.count(nextNode) || 0 == seenKey[nextNode].count(curKey))) {
int nextKey = curKey;
if('a' <= grid[nextX][nextY] && grid[nextX][nextY] <= 'f') {
nextKey = curKey | (0x1 << (grid[nextX][nextY] - 'a'));
}

curLevel.push({{nextX, nextY}, nextKey});
seenKey[{nextX, nextY}].insert(nextKey);
dis[{nextNode, nextKey}] = dis[curNode] + 1;

// cout << "next: " << nextX << "," << nextY << " charis "<< grid[nextX][nextY] << "-> " << bitset<6>(nextKey) << endl;
if(nextKey == tarKey) {
return dis[curNode] + 1;
}
}
}
}
}
return -1;
}
};

1 phon的光照和blinn-phon的光照

直接看这个shader:

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
35
36
37
38
39
40
41
42
43
44
45
46
#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;

uniform sampler2D floorTexture;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform bool blinn;

void main()
{
vec3 color = texture(floorTexture, fs_in.TexCoords).rgb;
// ambient
vec3 ambient = 0.05 * color;
// diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
vec3 normal = normalize(fs_in.Normal);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * color;
// specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
if(blinn)
{
// 然而blinn光照,就考虑了大于90度的问题,
// 将theta角度定义为了视线和光线的中间向量,然后和表面法向的夹角
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);
}
else
{
// phong的光照里,有这个反射光线和视线的点乘,代表了反射光线和眼睛看向反光点的视线夹角有多小,
// 越小漫反射效果就越好,cos(theta)嘛!
// theta很容易想到会大于90度,那么漫反射就失效了
vec3 reflectDir = reflect(-lightDir, normal);
spec = pow(max(dot(viewDir, reflectDir), 0.0), 8.0);
}
vec3 specular = vec3(0.3) * spec; // assuming bright white light color
FragColor = vec4(ambient + diffuse + specular, 1.0);
}

2 gamma矫正

一句话理解他: 物理显示器显示(线性空间)的颜色亮度为0.5,人看到的亮度会为0.5^2.2,也就是更暗了,于是需要先做1/2.2次幂的拔高

Gamma校正(Gamma Correction)的思路是在最终的颜色输出上应用监视器Gamma的倒数。回头看前面的Gamma曲线图,你会有一个短划线,它是监视器Gamma曲线的翻转曲线。我们在颜色显示到监视器的时候把每个颜色输出都加上这个翻转的Gamma曲线,这样应用了监视器Gamma以后最终的颜色将会变为线性的。我们所得到的中间色调就会更亮,所以虽然监视器使它们变暗,但是我们又将其平衡回来了。
我们来看另一个例子。还是那个暗红色(0.5,0.0,0.0)。在将颜色显示到监视器之前,我们先对颜色应用Gamma校正曲线。线性的颜色显示在监视器上相当于降低了2.2次幂的亮度,所以倒数就是1/2.2次幂。Gamma校正后的暗红色就会成为(0.5,0.0,0.0)1/2.2=(0.5,0.0,0.0)0.45=(0.73,0.0,0.0)。校正后的颜色接着被发送给监视器,最终显示出来的颜色是(0.73,0.0,0.0)2.2=(0.5,0.0,0.0)。你会发现使用了Gamma校正,监视器最终会显示出我们在应用中设置的那种线性的颜色
总而言之,gamma校正使你可以在线性空间中进行操作。因为线性空间更符合物理世界,大多数物理公式现在都可以获得较好效果,比如真实的光的衰减。你的光照越真实,使用gamma校正获得漂亮的效果就越容易。这也正是为什么当引进gamma校正时,建议只去调整光照参数的原因。

在使用了gamma校正之后,另一个不同之处是光照衰减(Attenuation)。真实的物理世界中,光照的衰减和光源的距离的平方成反比。但是由于本身有gamma矫正,所以我们就用双曲线函数衰减就行了,因为最后会乘以2.2次幂!约等于距离平方反比

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;

uniform sampler2D floorTexture;

uniform vec3 lightPositions[4];
uniform vec3 lightColors[4];
uniform vec3 viewPos;
uniform bool gamma;

vec3 BlinnPhong(vec3 normal, vec3 fragPos, vec3 lightPos, vec3 lightColor)
{
// diffuse
vec3 lightDir = normalize(lightPos - fragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// specular
vec3 viewDir = normalize(viewPos - fragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// simple attenuation
float max_distance = 1.5;
float distance = length(lightPos - fragPos);
// gamma开了就是双曲线衰减,然后由于线性到视觉,有个2.2次幂,相当于是2次幂的距离衰减
float attenuation = 1.0 / (gamma ? distance * distance : distance);

diffuse *= attenuation;
specular *= attenuation;

return diffuse + specular;
}

void main()
{
vec3 color = texture(floorTexture, fs_in.TexCoords).rgb;
vec3 lighting = vec3(0.0);
for(int i = 0; i < 4; ++i)
lighting += BlinnPhong(normalize(fs_in.Normal), fs_in.FragPos, lightPositions[i], lightColors[i]);
color *= lighting;
if(gamma) // 手动gamma矫正,之前调用一个ogl的函数将纹理变为线性空间的纹理
color = pow(color, vec3(1.0/2.2));
FragColor = vec4(color, 1.0);
}

3 阴影映射(定向阴影贴图技术)(基于光照空间的深度缓冲和正常渲染空间的深度缓冲做比较来实现阴影效果)

一句话理解:对于场景的每个顶点转换到光源为中心的坐标系里,然后渲染场景得到的z值就是光源能看到它的深度,然后借用原本渲染场景时,会有一个深度值z‘,比较这两个值,就知道在这个像素是否能够直面光源

  • 效果不错,但它只适合定向光,因为阴影只是在单一定向光源下生成的。它也叫定向阴影映射,深度(阴影)贴图生成自定向光的视角。
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
// 1. 首选渲染深度贴图
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices(); // 将目标的深度信息存在这个depthMapFBO里面
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. 绘制深度贴图,将深度可视化的意思
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // clear掉之前为了得到深度缓冲的物体
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_2D, depthMap);
RenderScene();
// ------------------------------------利用光照空间生成光照里面的深度缓存对应的vs
#version 330 core
layout (location = 0) in vec3 aPos;

uniform mat4 lightSpaceMatrix;
uniform mat4 model;

void main()
{
gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0);
}


然后利用这个光照空间深度buffer,得到阴影是否该渲染,直接看shader:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
} fs_in;

uniform sampler2D diffuseTexture;
uniform sampler2D shadowMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

float ShadowCalculation(vec4 fragPosLightSpace)
{
// perform perspective divide
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// transform to [0,1] range
projCoords = projCoords * 0.5 + 0.5;
// get closest depth value from light's perspective (using [0,1] range fragPosLight as coords)
float closestDepth = texture(shadowMap, projCoords.xy).r;
// get depth of current fragment from light's perspective
float currentDepth = projCoords.z;
// calculate bias (based on depth map resolution and slope)
vec3 normal = normalize(fs_in.Normal);
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
// 解决阴影失真问题,由于可能出现多个个pixel对应同一个深度texel,然后深度texel是呈现一个角度照射地面的
// 那么有一些pixel上的texel比地面小,有一些就比地面深度值大,解决办法就是当渲染深度和texel深度的误差很小
// 我们将其认为是无阴影,然后渲染即可
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
// if(abs(currentDepth - closestDepth) <= offset) {
// shadow = 0;
// }
// check whether current frag pos is in shadow
// float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
// PCF
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for(int x = -1; x <= 1; ++x)
{
for(int y = -1; y <= 1; ++y)
{
// 因为深度贴图有一个固定的分辨率,多个片段对应于一个纹理像素。
// 结果就是多个片段会从深度贴图的同一个深度值进行采样,这几个片段便得到的是同一个阴影,这就会产生锯齿边。
// 导致锯齿严重
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;

// keep the shadow at 0.0 when outside the far_plane region of the light's frustum.
if(projCoords.z > 1.0)
shadow = 0.0;

return shadow;
}

void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(0.3);
// ambient
vec3 ambient = 0.3 * lightColor;
// diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// calculate shadow
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;

FragColor = vec4(lighting, 1.0);
}

4 点光源阴影(万向阴影贴图(omnidirectional shadow maps)技术)

算法本身:我们从光的透视图生成一个深度贴图,基于当前fragment位置来对深度贴图采样,然后用储存的深度值和每个fragment进行对比,看看它是否在阴影中。定向阴影映射和万向阴影映射的主要不同在于深度贴图的使用上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
万向阴影贴图有两个渲染阶段:首先我们生成深度贴图,然后我们正常使用深度贴图渲染,在场景中创建阴影。帧缓冲对象和立方体贴图的处理看起是这样的:

// 1. first render to depth cubemap
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. then render scene as normal with shadow mapping (using depth cubemap)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
RenderScene();

由于万向阴影贴图基于传统阴影映射的原则,它便也继承了由解析度产生的非真实感。如果你放大就会看到锯齿边了。PCF或称Percentage-closer filtering允许我们通过对fragment位置周围过滤多个样本,并对结果平均化。

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
35
36
37
38
39
40
41
42
43
44
45
46
float shadow = 0.0;
float bias = 0.05;
float samples = 4.0;
float offset = 0.1;
for(float x = -offset; x < offset; x += offset / (samples * 0.5))
{
for(float y = -offset; y < offset; y += offset / (samples * 0.5))
{
for(float z = -offset; z < offset; z += offset / (samples * 0.5))
{
float closestDepth = texture(depthMap, fragToLight + vec3(x, y, z)).r;
closestDepth *= far_plane; // Undo mapping [0;1]
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
}
}
shadow /= (samples * samples * samples);
// 然而,samples设置为4.0,每个fragment我们会得到总共64个样本,这太多了!大多数这些采样都是多余的,与其在原始方向向量附近处采样,不如在采样方向向量的垂直方向进行采样更有意义。可是,没有(简单的)方式能够指出哪一个子方向是多余的,这就难了。有个技巧可以使用,用一个偏移量方向数组,它们差不多都是分开的,每一个指向完全不同的方向,剔除彼此接近的那些子方向。下面就是一个有着20个偏移方向的数组:
vec3 sampleOffsetDirections[20] = vec3[]
(
vec3( 1, 1, 1), vec3( 1, -1, 1), vec3(-1, -1, 1), vec3(-1, 1, 1),
vec3( 1, 1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1), vec3(-1, 1, -1),
vec3( 1, 1, 0), vec3( 1, -1, 0), vec3(-1, -1, 0), vec3(-1, 1, 0),
vec3( 1, 0, 1), vec3(-1, 0, 1), vec3( 1, 0, -1), vec3(-1, 0, -1),
vec3( 0, 1, 1), vec3( 0, -1, 1), vec3( 0, -1, -1), vec3( 0, 1, -1)
);
// 然后我们把PCF算法与从sampleOffsetDirections得到的样本数量进行适配,使用它们从立方体贴图里采样。这么做的好处是与之前的PCF算法相比,我们需要的样本数量变少了。

float shadow = 0.0;
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
float diskRadius = 0.05;
for(int i = 0; i < samples; ++i)
{
float closestDepth = texture(depthMap, fragToLight + sampleOffsetDirections[i] * diskRadius).r;
closestDepth *= far_plane; // Undo mapping [0;1]
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);

// 另一个在这里可以应用的有意思的技巧是,我们可以基于观察者里一个fragment的距离来改变diskRadius;这样我们就能根据观察者的距离来增加偏移半径了,当距离更远的时候阴影更柔和,更近了就更锐利。

float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;

5 法线贴图(模拟光照)

一句话:为每个fragment生成一个法向,更真实地模拟光照
考虑一个问题,当光照在z轴,然后墙面法向也是z轴,那么法线贴图的每个法线都指向z轴,者能够正常工作,但是当墙面指向正y方向,法向应该能随着墙面旋转而旋转,然后我们没有改动法向,那么就会产生错误的光照!
一个稍微有点难的解决方案是,在一个不同的坐标空间中进行光照,这个坐标空间里,法线贴图向量总是指向这个坐标空间的正z方向;所有的光照向量都相对与这个正z方向进行变换。这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做切线空间(tangent space)。
方法就是,纹理的相邻两边叉乘得到法向量得到TBN矩阵(切线、副切线、法向),有两种方式使用:

  • 1 法线坐标左乘上TBN矩阵,转换到世界坐标空间中,这样所有法线和其他光照变量就在同一个坐标系中了。(在着色器里传入这个向量即可,然后对于着色器里的法向向量乘以TBN矩阵)
  • 2 TBN矩阵的逆矩阵,这个矩阵可以把世界坐标空间的向量转换到切线坐标空间。因此我们使用这个矩阵左乘其他光照变量,把他们转换到切线空间,这样法线和其他光照变量再一次在一个坐标系中了。(正交矩阵(每个轴既是单位向量同时相互垂直)的一大属性是一个正交矩阵的置换矩阵与它的逆矩阵相等。所有我们对正交矩阵求逆一般都是直接transpose,而不是inverse)

    第二种方法看似要做的更多,它还需要在像素着色器中进行更多的乘法操作,所以为何还用第二种方法呢?(将lightpos viewpos等等都在顶点着色器就转换到了切线空间,避免了在像素着色器阶段做这件事)
    将向量从世界空间转换到切线空间有个额外好处,我们可以把所有相关向量在顶点着色器中转换到切线空间,不用在像素着色器中做这件事。这是可行的,因为lightPos和viewPos不是每个fragment运行都要改变,对于fs_in.FragPos,我们也可以在顶点着色器计算它的切线空间位置。基本上,不需要把任何向量在像素着色器中进行变换,而第一种方法中就是必须的,因为采样出来的法线向量对于每个像素着色器都不一样。
    所以现在不是把TBN矩阵的逆矩阵发送给像素着色器,而是将切线空间的光源位置,观察位置以及顶点位置发送给像素着色器。这样我们就不用在像素着色器里进行矩阵乘法了。这是一个极佳的优化,因为顶点着色器通常比像素着色器运行的少。这也是为什么这种方法是一种更好的实现方式的原因。

使用法线贴图的优势

  • 1 更漂亮
  • 2 保持细节,高精度网格和使用法线贴图的低精度网格几乎区分不出来。所以法线贴图不仅看起来漂亮,它也是一个将高精度多边形转换为低精度多边形而不失细节的重要工具。

对于网格渲染,共享顶点的TBN法向会被平均用于平滑效果,这样做有个问题,就是TBN向量可能会不能互相垂直,这意味着TBN矩阵不再是正交矩阵了。法线贴图可能会稍稍偏移,但这仍然可以改进。使用叫做格拉姆-施密特正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。在顶点着色器中我们这样做:

1
2
3
4
5
6
7
8
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
// re-orthogonalize T with respect to N
T = normalize(T - dot(T, N) * N);
// then retrieve perpendicular vector B with the cross product of T and N
vec3 B = cross(T, N);

mat3 TBN = mat3(T, B, N)

6 视差贴图(模拟深度)

一句话:视差贴图背后的思想是修改纹理坐标使一个fragment的表面看起来比实际的更高或者更低,所有这些都根据观察方向和高度贴图。

1
2
3
4
5
6
7
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
float height = texture(depthMap, texCoords).r;
vec2 p = viewDir.xy / viewDir.z * (height * height_scale); // 考虑这个p,这个p就是假设一开始的坐标是texCoords,对吧?然后我们有viewDirection看向texCoords的位置,然后我们取这个方向乘以这个点的理应高度(也就是想要渲染出来的高度),得到texCoords应该做的偏移以达到效果
// 上面除以z,是因为viewDir已经单位化了,所以会适当放大p,得到更大的偏移效果,这个看个人喜好了
return texCoords - p;
}

陡峭视差贴图(viewDircection多次采样,得到更精确的视差)

一句话:上面的直接用高度h在viewDirection方向采样去模拟偏移p,不够精确,那么对viewDirection方向上做很多个layer的采样,通过每个采样点和真实高度相比较,直到找到第一个比真实高度低的采样点作为结果即可!
上面我们可以知道,这个p只是我们利用viewDir乘以高度得到的偏移,那么我们可以考虑在viewDir多采样几个长度,会得到若干深度,有些大于目标深度,有些小于,那么采样的个数我们把它叫做层数,层数越高就越能逼近真实值
而且这种情况你很容易知道,随着采样层数的增多,砖体上凹下去的横纹会渐渐消失(用1280测试过),因为采样层数少了以后,高度相近的fragment(实际不相同)会最终偏移到同一个纹理坐标,导致横纹

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} fs_in;

uniform sampler2D diffuseMap;
uniform sampler2D normalMap;
uniform sampler2D depthMap;

uniform float heightScale;

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
// number of depth layers
const float minLayers = 8;
const float maxLayers = 32;
float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));
// calculate the size of each layer
float layerDepth = 1.0 / numLayers;
// depth of current layer
float currentLayerDepth = 0.0;
// the amount to shift the texture coordinates per layer (from vector P)
vec2 P = viewDir.xy / viewDir.z * heightScale;
vec2 deltaTexCoords = P / numLayers;

// get initial values
vec2 currentTexCoords = texCoords;
float currentDepthMapValue = texture(depthMap, currentTexCoords).r;

while(currentLayerDepth < currentDepthMapValue)
{
// shift texture coordinates along direction of P
currentTexCoords -= deltaTexCoords;
// get depthmap value at current texture coordinates
currentDepthMapValue = texture(depthMap, currentTexCoords).r;
// get depth of next layer
currentLayerDepth += layerDepth;
}

return currentTexCoords;
}

void main()
{
// offset texture coordinates with Parallax Mapping
vec3 viewDir = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);
vec2 texCoords = fs_in.TexCoords;

texCoords = ParallaxMapping(fs_in.TexCoords, viewDir);
if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0)
discard;

// obtain normal from normal map
vec3 normal = texture(normalMap, texCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);

// get diffuse color
vec3 color = texture(diffuseMap, texCoords).rgb;
// ambient
vec3 ambient = 0.1 * color;
// diffuse
vec3 lightDir = normalize(fs_in.TangentLightPos - fs_in.TangentFragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * color;
// specular
vec3 reflectDir = reflect(-lightDir, normal);
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), 32.0);

vec3 specular = vec3(0.2) * spec;
FragColor = vec4(ambient + diffuse + specular, 1.0);
}

视差遮蔽映射

一句话:相比较与陡峭视差映射,我们采用和真实高度最相近的两个layer线性差值得到最终结果
视差遮蔽映射(Parallax Occlusion Mapping)和陡峭视差映射的原则相同,但不是用触碰的第一个深度层的纹理坐标(本来的过程不是说:从最高layer每个采样点去比较,直到遇到第一个比他小的,然后就作为最终的偏移结果嘛),而是在触碰之前和之后这两个layer,在深度层之间进行一次线性插值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[...] // steep parallax mapping code here

// get texture coordinates before collision (reverse operations)
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;

// get depth after and before collision for linear interpolation
float afterDepth = currentDepthMapValue - currentLayerDepth;
float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth;

// interpolation of texture coordinates
float weight = afterDepth / (afterDepth - beforeDepth);
vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);

return finalTexCoords;

7 HDR高动态范围

一句话:我们能做的是用一个不同的方程与/或曲线来转换这些HDR(渲染过程中的连读)值到LDR(真实渲染的亮度)值,从而给我们对于场景的亮度完全掌控,这就是之前说的色调变换,也是HDR渲染的最终步骤。

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
35
36

float useExposureOnlyWhenDark(vec3 hdrColor) {
if(length(hdrColor) < 1) {
return exposure;
} else {
return 1;
}
}

void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
if(hdr)
{
// reinhard
// vec3 result = hdrColor / (hdrColor + vec3(1.0));
// exposure
// vec3 result = vec3(1.0) - exp(-hdrColor * useExposureOnlyWhenDark(hdrColor));
vec3 result = vec3(1.0) - exp(-hdrColor * useExposureOnlyWhenDark(hdrColor));
// vec3 result = vec3(1.0) - exp(-hdrColor * exposure);
// also gamma correct while we're at it
result = pow(result, vec3(1.0 / gamma));
FragColor = vec4(result, 1.0);
}
else
{
// Reinhard色调映射
vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));

FragColor = vec4(mapped, 1.0);
}
}

8 泛光

一句话:对于高亮的东西先取出来,然后blur掉,然后再和原来的combine得到泛光

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 具体算法流程:
// 1. 取出高亮:
#version 330 core
// 像这样对一个帧缓冲对象添加多个颜色或者深度缓冲对象,就是MRT技术(多渲染目标技术)
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
...
void main()
{
...
// check whether result is higher than some threshold, if so, output as bloom threshold color
float brightness = dot(result, vec3(0.2126, 0.7152, 0.0722));
if(brightness > 1.0)
BrightColor = vec4(result, 1.0);
else
BrightColor = vec4(0.0, 0.0, 0.0, 1.0);
FragColor = vec4(result, 1.0);
}

// 2. 高斯模糊
// 幸运的是,高斯方程有个非常巧妙的特性,它允许我们把二维方程分解为两个更小的方程:一个描述水平权重,另一个描述垂直权重。我们首先用水平权重在整个纹理上进行水平模糊,然后在经改变的纹理上进行垂直模糊。利用这个特性,结果是一样的,但是可以节省难以置信的性能,因为我们现在只需做32+32次采样,不再是1024了!这叫做两步高斯模糊。
void main()
{
vec2 tex_offset = 1.0 / textureSize(image, 0); // gets size of single texel
vec3 result = texture(image, TexCoords).rgb * weight[0];
if(horizontal)
{
for(int i = 1; i < 5; ++i)
{
result += texture(image, TexCoords + vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
result += texture(image, TexCoords - vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
}
}
else
{
for(int i = 1; i < 5; ++i)
{
result += texture(image, TexCoords + vec2(0.0, tex_offset.y * i)).rgb * weight[i];
result += texture(image, TexCoords - vec2(0.0, tex_offset.y * i)).rgb * weight[i];
}
}
FragColor = vec4(result, 1.0);
}

// 3 混合起来:
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(scene, TexCoords).rgb;
vec3 bloomColor = texture(bloomBlur, TexCoords).rgb;
if(bloom)
hdrColor += bloomColor; // additive blending
// tone mapping
vec3 result = vec3(1.0) - exp(-hdrColor * exposure);
// also gamma correct while we're at it
result = pow(result, vec3(1.0 / gamma));
FragColor = vec4(result, 1.0);
}

9 延迟着色法

一句话:通常用的正向渲染(forward shading)对于每一个光源和每一个渲染片段都进行了迭代,计算量很大!而且大部分片段着色器输出之后会被之后的输出覆盖,很多时间浪费,于是我们把法向,镜像贴图颜色等等都先放到gBuffer,然后fragmentShader从gbuffer中读取数据渲染即可

有缺点:

  • 1 不能进行混合(Blending),因为G缓冲中所有的数据都是从一个单独的片段中来的,而混合需要对多个片段的组合进行操作·
  • 2 它迫使你对大部分场景的光照使用相同的光照算法
    为了克服这些缺点(特别是混合),我们通常分割我们的渲染器为两个部分:一个是延迟渲染的部分,另一个是专门为了混合或者其他不适合延迟渲染管线的着色器效果而设计的的正向渲染的部分(比如光照物体,需要gbuffer中的场景物体的深度,那么我们会把这个gbuffer的深度信息在渲染光照物体之前copy出来,然后渲染光照物体之前绑定,让光照物体有这些深度信息)。为了展示这是如何工作的,我们将会使用正向渲染器渲染光源为一个小立方体,因为光照立方体会需要一个特殊的着色器(会输出一个光照颜色)。

延迟渲染一直被称赞的原因就是它能够渲染大量的光源而不消耗大量的性能。然而,延迟渲染它本身并不能支持非常大量的光源,因为我们仍然必须要对场景中每一个光源计算每一个片段的光照分量。真正让大量光源成为可能的是我们能够对延迟渲染管线引用的一个非常棒的优化:光体积(Light Volumes)(计算每个光源的可照明半径,仅渲染球体内部像素,超出部分不渲染)

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedoSpec;

struct Light {
vec3 Position;
vec3 Color;

float Linear;
float Quadratic;
float Radius;
};
const int NR_LIGHTS = 32;
uniform Light lights[NR_LIGHTS];
uniform vec3 viewPos;

void main()
{
// retrieve data from gbuffer
vec3 FragPos = texture(gPosition, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Diffuse = texture(gAlbedoSpec, TexCoords).rgb;
float Specular = texture(gAlbedoSpec, TexCoords).a;

// then calculate lighting as usual
vec3 lighting = Diffuse * 0.1; // hard-coded ambient component
vec3 viewDir = normalize(viewPos - FragPos);
for(int i = 0; i < NR_LIGHTS; ++i)
{
// calculate distance between light source and current fragment
// 计算每个光源的可照明半径,仅渲染球体内部像素,超出部分不渲染
float distance = length(lights[i].Position - FragPos);
if(distance < lights[i].Radius)
{
// diffuse
vec3 lightDir = normalize(lights[i].Position - FragPos);
vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * lights[i].Color;
// specular
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(Normal, halfwayDir), 0.0), 16.0);
vec3 specular = lights[i].Color * spec * Specular;
// attenuation
float attenuation = 1.0 / (1.0 + lights[i].Linear * distance + lights[i].Quadratic * distance * distance);
diffuse *= attenuation;
specular *= attenuation;
lighting += diffuse + specular;
}
}
FragColor = vec4(lighting, 1.0);
}

仅仅是延迟着色法它本身(没有光体积)已经是一个很大的优化了,每个像素仅仅运行一个单独的片段着色器,然而对于正向渲染,我们通常会对一个像素运行多次片段着色器。当然,延迟渲染确实带来一些缺点:大内存开销,没有MSAA和混合(仍需要正向渲染的配合)。

10 SSAO(sscreen-space ambient occlusion)屏幕空间环境光遮蔽

一句话:给环境光照加上一个遮蔽因子,决定环境光照的强弱,简单的来说,在凹下去的地方要暗一点,就这个需求,对!
算法核心:若一个点周围的深度都比他高,那么我们增加遮蔽因子,在目标周围的法向半球型附近随机采样即可。

很明显,渲染效果的质量和精度与我们采样的样本数量有直接关系。如果样本数量太低,渲染的精度会急剧减少,我们会得到一种叫做波纹(Banding)的效果;如果它太高了,反而会影响性能。我们可以通过引入随机性到采样核心(Sample Kernel)的采样中从而减少样本的数目。通过随机旋转采样核心,我们能在有限样本数量中得到高质量的结果。然而这仍然会有一定的麻烦,因为随机性引入了一个很明显的噪声图案,我们将需要通过模糊结果来修复这一问题。

因为核心中一半的样本都会在墙这个几何体上。下面这幅图展示了孤岛危机的SSAO,它清晰地展示了这种灰蒙蒙的感觉,由于这个原因,我们将不会使用球体的采样核心,而使用一个沿着表面法向量的半球体采样核心。通过在法向半球体(Normal-oriented Hemisphere)周围采样,我们将不会考虑到片段底部的几何体.它消除了环境光遮蔽灰蒙蒙的感觉,从而产生更真实的结果。

如下是大体流程以及shader中的某些实现:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
    // render循环:
// 1. geometry pass: render scene's geometry/color data into gbuffer
// 把ssao shader需要的信息先放到gbuffer里面,包括:
// 逐片段位置向量
// 逐片段的法线向量
// 逐片段的反射颜色
// -----------------------------------------------------------------
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
...
backpack.Draw(shaderGeometryPass);
glBindFramebuffer(GL_FRAMEBUFFER, 0);


// 2. generate SSAO texture
// ------------------------
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
glClear(GL_COLOR_BUFFER_BIT);
shaderSSAO.use();
// Send kernel + rotation,将循环外部生成好的kernel设置到shader里面
// 采样核心 用来旋转采样核心的随机旋转矢量 在这一步送入
for (unsigned int i = 0; i < 64; ++i)
shaderSSAO.setVec3("samples[" + std::to_string(i) + "]", ssaoKernel[i]);
shaderSSAO.setMat4("projection", projection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
renderQuad();
glBindFramebuffer(GL_FRAMEBUFFER, 0);


// 3. blur SSAO texture to remove noise
// ------------------------------------
glBindFramebuffer(GL_FRAMEBUFFER, ssaoBlurFBO);
glClear(GL_COLOR_BUFFER_BIT);
// 简单的对产生的ssao纹理进行一个模糊,为了创建一个光滑的环境遮蔽结果,我们需要模糊环境遮蔽纹理。
shaderSSAOBlur.use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
renderQuad();
glBindFramebuffer(GL_FRAMEBUFFER, 0);


// 4. lighting pass: traditional deferred Blinn-Phong lighting with added screen-space ambient occlusion
// -----------------------------------------------------------------------------------------------------
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shaderLightingPass.use();
// send light relevant uniforms
glm::vec3 lightPosView = glm::vec3(camera.GetViewMatrix() * glm::vec4(lightPos, 1.0));
shaderLightingPass.setVec3("light.Position", lightPosView);
shaderLightingPass.setVec3("light.Color", lightColor);
// Update attenuation parameters
const float linear = 0.09f;
const float quadratic = 0.032f;
shaderLightingPass.setFloat("light.Linear", linear);
shaderLightingPass.setFloat("light.Quadratic", quadratic);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, gAlbedo);
glActiveTexture(GL_TEXTURE3); // add extra SSAO texture to lighting pass
glBindTexture(GL_TEXTURE_2D, ssaoColorBufferBlur);
renderQuad();

// ----------------------------------- phase1 gbuffer获取纹理,法向,反射率给ssao shader
#version 330 core
layout (location = 0) out vec3 gPosition;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec3 gAlbedo;

in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;

void main()
{
// store the fragment position vector in the first gbuffer texture
gPosition = FragPos;
// also store the per-fragment normals into the gbuffer
gNormal = normalize(Normal);
// and the diffuse per-fragment color
gAlbedo.rgb = vec3(0.95);
}

// ----------------------------------- phase2 ssao 生成阶段
#version 330 core
out float FragColor;

in vec2 TexCoords;

// 分别用多纹理附件将需要的数据bind进来
uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D texNoise;

// 渲染循环前就设置好
uniform vec3 samples[64];

// parameters (you'd probably want to use them as uniforms to more easily tweak the effect)
int kernelSize = 64; // 减小然后去掉模糊,我们看一下ssao带来的波纹
float radius = 0.5;
float bias = 0.025;

// 屏幕的平铺噪声纹理会根据屏幕分辨率除以噪声大小的值来决定
// tile noise texture over screen based on screen dimensions divided by noise size
const vec2 noiseScale = vec2(800.0/4.0, 600.0/4.0);

uniform mat4 projection;

void main()
{
// get input for SSAO algorithm
vec3 fragPos = texture(gPosition, TexCoords).xyz;
vec3 normal = normalize(texture(gNormal, TexCoords).rgb);
vec3 randomVec = normalize(texture(texNoise, TexCoords * noiseScale).xyz);
// create TBN change-of-basis matrix: from tangent-space to view-space
// 由于对每个表面法线方向生成采样核心非常困难,也不合实际,我们将在切线空间(Tangent Space)内生成采样核心,法向量将指向正z方向。
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
// iterate over the sample kernel and calculate occlusion factor
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{
// get sample position
vec3 samplePos = TBN * samples[i]; // from tangent to view-space
samplePos = fragPos + samplePos * radius;

// project sample position (to sample texture) (to get position on screen/texture)
vec4 offset = vec4(samplePos, 1.0);
offset = projection * offset; // from view to clip-space
offset.xyz /= offset.w; // perspective divide
offset.xyz = offset.xyz * 0.5 + 0.5; // transform to range 0.0 - 1.0

// get sample depth
float sampleDepth = texture(gPosition, offset.xy).z; // get depth value of kernel sample

// 。当检测一个靠近表面边缘的片段时,它将会考虑测试表面之下的表面的深度值;这些值将会(不正确地)影响遮蔽因子。
// range check & accumulate, 在这里根据它非常光滑地在第一和第二个参数范围内插值了第三个参数。如果深度差因此最终取值在radius之间,
// 它们的值将会光滑地根据下面这个曲线插值在0.0和1.0之间
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
}
occlusion = 1.0 - (occlusion / kernelSize);

FragColor = occlusion;
}

// ----------------------------------- phase3 ssao 由于重复的纹理噪声(相同的环境因子按条纹出现),于是有模糊(平滑)阶段
#version 330 core
out float FragColor;

in vec2 TexCoords;

uniform sampler2D ssaoInput;

void main()
{
vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0));
float result = 0.0;
for (int x = -2; x < 2; ++x)
{
for (int y = -2; y < 2; ++y)
{
vec2 offset = vec2(float(x), float(y)) * texelSize;
result += texture(ssaoInput, TexCoords + offset).r;
}
}
FragColor = result / (4.0 * 4.0);
}

// ----------------------------------- phase4 bling phon光照模型
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;
uniform sampler2D ssao;

struct Light {
vec3 Position;
vec3 Color;

float Linear;
float Quadratic;
};
uniform Light light;

void main()
{
// retrieve data from gbuffer
vec3 FragPos = texture(gPosition, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Diffuse = texture(gAlbedo, TexCoords).rgb;
float AmbientOcclusion = texture(ssao, TexCoords).r;

// then calculate lighting as usual
vec3 ambient = vec3(0.3 * Diffuse * AmbientOcclusion);
vec3 lighting = ambient;
vec3 viewDir = normalize(-FragPos); // viewpos is (0.0.0)
// diffuse
vec3 lightDir = normalize(light.Position - FragPos);
vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * light.Color;
// specular
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(Normal, halfwayDir), 0.0), 8.0);
vec3 specular = light.Color * spec;
// attenuation
float distance = length(light.Position - FragPos);
float attenuation = 1.0 / (1.0 + light.Linear * distance + light.Quadratic * distance * distance);
diffuse *= attenuation;
specular *= attenuation;
lighting += diffuse + specular;

FragColor = vec4(lighting, 1.0);
}



1 渲染流水线概览

https://raw.githubusercontent.com/xychen5/blogImgs/main/imgs/Renderpipeline_openGL.n9246f3kae8.webp

https://raw.githubusercontent.com/xychen5/blogImgs/main/imgs/RTR_renderPipeline2.5gqxc1hwauo0.webp

1.1 几何阶段

  • 1 模型和视图变换阶段:模型变换的目的是将模型变换到适合渲染的空间当中,而视图变换的目的是将摄像机放置于坐标原点,方便后续步骤的操作。
  • 2 顶点着色阶段(vertex shading过程):顶点着色的目的在于确定模型上顶点处材质的光照效果。
  • 3 投影阶段:投影阶段是将模型从三维空间投射到二维的空间中的过程。投影阶段也可以理解为将视体变换到一个对角顶点分别是(-1,-1,-1)和(1,1,1)单位立方体内的过程。
  • 4 几何着色器:几何着色器可以在顶点发送到下一着色器阶段之前对它们随意变换。几何着色器输出的形式只能是点,折线和三角形条。
  • 5 裁剪阶段:裁剪阶段的目的,是对部分位于视体内部的图元进行裁剪操作。
  • 6 屏幕映射阶段:屏幕映射阶段的主要目的,是将之前步骤得到的坐标映射到对应的屏幕坐标系上。

1.1.1 结合具体代码描述vertex shading过程

  • 1 顶点着色器:是完全可编程的阶段,是专门处理传入的顶点信息的着色器,顶点着色器可以对每个顶点 进行诸如变换和变形在内的很多操作。顶点着色器一般不处理附加信息,也就是说,顶点着色器提供 了修改,创建,或者忽略与每个多边形顶点相关的值的方式,例如其颜色,法线,纹理坐标和位置。 通常,顶点着色器程序将顶点从模型空间(Model Space)变换到齐次裁剪空间(Homogeneous Clip Space),并且,一个顶点着色器至少且必须输出此变换位置(以便于像素着色阶段使用)。
    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
    35
    36
    37
    // 顶点shading的具体过程
    // -------------------------------- 控制 mvp --------------------------------------------
    // pass projection matrix to shader (as projection matrix rarely changes there's no need to do this per frame)
    // -----------------------------------------------------------------------------------------------------------
    glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
    ourShader.setMat4("projection", projection);
    ...
    // camera/view transformation
    glm::mat4 view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
    ourShader.setMat4("view", view);
    ...
    model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
    ourShader.setMat4("model", model);

    // -------------------------------- vertex shader 代码 --------------------------------
    #version 330 core
    layout (location = 0) in vec3 aPos;
    layout (location = 1) in vec3 aNormal;
    layout (location = 2) in vec2 aTexCoords;

    out vec3 FragPos;
    out vec3 Normal;
    out vec2 TexCoords;

    uniform mat4 model;
    uniform mat4 view;
    uniform mat4 projection;

    void main()
    {
    FragPos = vec3(model * vec4(aPos, 1.0));
    Normal = mat3(transpose(inverse(model))) * aNormal;
    TexCoords = aTexCoords;

    gl_Position = projection * view * vec4(FragPos, 1.0); // 转换以后给opengl绘制顶点的坐标
    }

1.1.2 结合具体代码说明geometry shader的代码

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
#version 330 core
layout (triangles) in; // 告诉进来的东西是三角形,于是下面画了3个法向
layout (line_strip, max_vertices = 6) out;

in VS_OUT {
vec3 normal; // vertexshader计算的法向
} gs_in[];

const float MAGNITUDE = 0.2; // 法线长度

uniform mat4 projection;

void GenerateLine(int index)
{
gl_Position = projection * gl_in[index].gl_Position;
EmitVertex();
gl_Position = projection * (gl_in[index].gl_Position + vec4(gs_in[index].normal, 0.0) * MAGNITUDE);
EmitVertex();
EndPrimitive();
}

void main()
{
// 对于三个顶点,都去做这个
GenerateLine(0); // first vertex normal
GenerateLine(1); // second vertex normal
GenerateLine(2); // third vertex normal
}

1.2 光栅阶段

  • 1 三角形设定阶段(不可配置):三角形设定阶段主要用来计算三角形表面的差异和三角形表面的其他相关数据。
  • 2 三角形遍历阶段(不可配置):找到哪些采样点或像素在三角形中的过程通常叫三角形遍历。
  • 3 像素着色阶段(fragment shading过程):像素着色阶段的主要目的是计算所有需逐像素计算操作的过程。
  • 4 融合阶段:融合阶段的主要任务是合成当前储存于缓冲器中的由之前的像素着色阶段产生的片段颜色。此外,融合阶段还负责可见性问题(Z 缓冲相关)的处理。

1.2.1 像素着色

fragment shading和融合阶段过程,有较多的步骤:

  • 1 像素着色:用来处理场景光照和与之相关的效果,如凸凹纹理映射和调色。名称片断着色器似乎更为准确,因为对于着色器的调用和屏幕上像素的显示并非一一对应。举个例子,对于一个像素,片断着色器可能会被调用若干次来决定它最终的颜色,那些被遮挡的物体也会被计算,直到最后的深度缓冲才将各物体前后排序。在最终合并阶段设置片段颜色以进行合并,而深度值也可以由像素着色器修改。模板缓冲(stencil buffer)值是不可修改的,而是将其传递到合并阶段(Merge Stage)。

1.2.3 融合阶段

  • 1 融合阶段:是将像素着色器中生成的各个片段的深度和颜色与帧缓冲结合在一起的地方。这个阶段也就是进行模板缓冲(Stencil-Buffer)和 Z 缓冲(Z-buffer)操作的地方。最常用于透明处理(Transparency)和合成操作(Compositing)的颜色混合(Color Blending)操作也是在这个阶段进行的。一下
    • 1.1 模板缓冲:大概就是GLFW给每个窗口库都配置一个模板缓冲,默认情况下,启用模板缓冲写入,就可以把物体对应的顶点的哪些像素位置的模板缓冲值写为1,然后模板缓冲为1的位置,场景对应的片段才会被渲染,通过模板缓冲才会进入深度缓冲阶段,很容易想到的就是为什么是这样的顺序?显然深度模板每一个像素的位置上很可能有多个buffer信息需要处理,计算量远大于模板缓冲,于是自然先进行模板缓冲
      • 1.1.1 比如具体的:轮廓算法,就可以通过模板缓冲来实现:简单解释:
        • 当你有一个物体,你渲染前,先开启模板缓冲,把这个物体对应的模板缓冲都写成1,然后关闭模板缓冲,避免用于轮廓的物体也去写模板缓冲了
        • 然后你把这个物体稍微放大,绘制之前, glStencilFunc(GL_NOTEQUAL, 1, 0xFF);也就是不为1的地方,glStencilFunc描述了OpenGL应该对模板缓冲内容做什么,也就是不为1的地方,会通过模板测试,之后用一个简单的带有颜色的片段着色器着色画出这个物体即可形成了轮廓

          在绘制(需要添加轮廓的)物体之前,将模板函数设置为GL_ALWAYS,每当物体的片段被渲染时,将模板缓冲更新为1。
          渲染物体。
          禁用模板写入以及深度测试。
          将每个物体缩放一点点。
          使用一个不同的片段着色器,输出一个单独的(边框)颜色。
          再次绘制物体,但只在它们片段的模板值不等于1时才绘制。
          再次启用模板写入和深度测试。

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          // 轮廓算法代码
          glEnable(GL_DEPTH_TEST);
          glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);

          glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

          glStencilMask(0x00); // 记得保证我们在绘制地板的时候不会更新模板缓冲
          normalShader.use();
          DrawFloor()

          glStencilFunc(GL_ALWAYS, 1, 0xFF);
          glStencilMask(0xFF);
          DrawTwoContainers();

          glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
          glStencilMask(0x00);
          glDisable(GL_DEPTH_TEST);
          shaderSingleColor.use();
          DrawTwoScaledUpContainers();
          glStencilMask(0xFF);
          glEnable(GL_DEPTH_TEST);
    • 1.2 深度缓冲:对于每一个像素,有多个顶点信息,通过计算顶点和当前camera距离的位置,我们决定谁去渲染,通常有这几种策略:默认情况下使用的深度函数是GL_LESS,它将会丢弃深度值大于等于当前深度缓冲值的所有片段。

      GL_ALWAYS 永远通过深度测试
      GL_NEVER 永远不通过深度测试
      GL_LESS 在片段深度值小于缓冲的深度值时通过测试
      GL_EQUAL 在片段深度值等于缓冲区的深度值时通过测试
      GL_LEQUAL 在片段深度值小于等于缓冲区的深度值时通过测试
      GL_GREATER 在片段深度值大于缓冲区的深度值时通过测试
      GL_NOTEQUAL 在片段深度值不等于缓冲区的深度值时通过测试
      GL_GEQUAL 在片段深度值大于等于缓冲区的深度值时通过测试

结合具体代码描述(pixel)fragment shading过程和融合阶段

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// --------------------------------- fragment(pixel) shader 代码 ----------------------------
#version 330 core
out vec4 FragColor;

struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
};

struct Light {
vec3 position;
vec3 direction;
float cutOff;
float outerCutOff;

vec3 ambient;
vec3 diffuse;
vec3 specular;

float constant;
float linear;
float quadratic;
};

in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoords;

uniform vec3 viewPos;
uniform Material material;
uniform Light light;

void main()
{
vec3 lightDir = normalize(light.position - FragPos);

// check if lighting is inside the spotlight cone
float theta = dot(lightDir, normalize(-light.direction));

// cutOff = cos(spotAngle / 2),判断当前物体和手电筒形成的夹角是不是在聚光灯内部,不是的话就只用环境光就行
if(theta > light.cutOff) // remember that we're working with angles as cosines instead of degrees so a '>' is used.
{
// ambient 环境光
vec3 ambient = light.ambient * texture(material.diffuse, TexCoords).rgb;

// diffuse 散射
vec3 norm = normalize(Normal);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * texture(material.diffuse, TexCoords).rgb;

// specular 镜面
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * spec * texture(material.specular, TexCoords).rgb;

// attenuation 衰减系数
float distance = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * (distance * distance));

// ambient *= attenuation; // remove attenuation from ambient, as otherwise at large distances the light would be darker inside than outside the spotlight due the ambient term in the else branche
diffuse *= attenuation;
specular *= attenuation;

vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}
else // 聚光灯外面
{
// else, use ambient light so scene isn't completely dark outside the spotlight.
FragColor = vec4(light.ambient * texture(material.diffuse, TexCoords).rgb, 1.0);
}
}

1 kmp算法

参考: https://oi-wiki.org/string/kmp/#_10

这里简单举个例子:
在text = abcccab中查找tar = ab出现次数,那么构造串: ab#abcccab,然后计算前缀函数:
a b # a b c c c a b
[0,0,0,1,2,0,0,0,1,2] = pi, 为前缀函数的结果,找出i>tar.size()且pi[i] == n的i的集合,每一个i - 2*tar.size()就是tar出现在text中的下标

2 例题

0214shortestPalindrome 最短回文串

1 题目

https://leetcode-cn.com/problems/shortest-palindrome/

2 解题思路

  • 1 使用KMP算法,能够在text(n)字符串中搜索出tar(m)字符串的所有出现位置复杂度为o(m+n),那么由于本题目求解的是最短回文串,也就是要求最少的头部添加,尽可能利用字符串本身的回文信息,于是这里看个例子:
    • 1.1 see: our target is to find the “b c c b”,so we use kmp
      • s = b c c b a e
      • rs = e a b c c b
      • all = b c c b a e # e a b c c b
    • 1.2 我们用s + 分隔符 + reverse_s得到all,对于all的最后一个前缀函数pi.back(),就说明了最长有多长的后缀和前缀相等,也就是b c c b这一个公共部分,那么吧剩余的拼上去就行了
      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
      35
      36
      37
      38
      class Solution {
      public:
      string shortestPalindrome(string s) {
      // prefix function && kmp: https://oi-wiki.org/string/kmp/#_10
      int len = s.size();
      string rs = s;
      reverse(rs.begin(), rs.end());

      string all = s + "#" + rs;
      vector<int> pi(all.size(), 0);

      // see: our target is to find the "b c c b",so we use kmp
      // s = b c c b a e
      // rs = e a b c c b
      // all = b c c b a e # e a b c c b
      // 求解pi[i]
      for(int i = 1; i < all.size(); ++i) {
      // i - 1的前缀函数的值,有s[0:j] == s[i - j : i - 1]
      int j = pi[i - 1];
      // 当s[i] != s[j],说明s[i]这个字符无法成为后缀的最后一个字符,此时pi[i] = 0,于是得一直找到下一个j,直到j = 0,或者s[i] == s[j]
      while(j > 0 && all[i] != all[j]) {
      j = pi[j - 1];
      }

      // 倘若s[i] == s[j],那么就有s[0:i-1]这个字串的前缀函数的值为j,然后加上最后一个字符s[i],所以j++
      if(all[i] == all[j]) {
      j++;
      }
      pi[i] = j;
      }

      int commonLen = pi.back();
      if(commonLen == len) {
      return s;
      }
      return rs.substr(0, len - commonLen) + s;
      }
      };

- chapter 1 -


item 1 理解模板类型推断

rem

  • 对于引用(模板参数类型是引用)推导时,有引⽤的实参会被视为⽆引⽤,他们的引⽤会被忽略(之后拼接上模板类型的&(若有的话))
  • 对于通⽤引⽤的推导,左值实参会被特殊对待
  • 对于传值类型推导,实参如果具有常量性和易变性会被忽略
  • 在模板类型推导时,数组或者函数实参会退化为指针,除⾮它们被⽤于初始化引⽤

item 2 理解auto推断

对于花括号的处理是auto类型推导和模板类型推导唯⼀不同的地⽅。当使⽤auto的变量使⽤花括号的语,
法进⾏初始化的时候,会推导出std::initializer_list的实例化,但是对于模板类型推导这样就⾏不通:

1
2
3
4
5
6
7
8
9
10
auto x={11,23,9}; //x的类型是std::initializer_list<int> 
template<typename T>
void f(T param);

f({11,23,9}); //错误!不能推导出T

// ----------
template<typename T>
void f(std::initializer_list<T> initList);
f({11,23,9}); //T被推导为int,initList的类型被推导为std::initializer_list<int>

以上是cpp11的,但是cpp14中允许函数的形参和返回值都为auto,但是推断还是模板推断那一套:

1
2
3
std::vector<int> v;
auto resetV = [&v](const auto & newValue){v=newValue;}; //C++14 ...
reset({1,2,3}); //错误!推导失败

rem

  • auto类型推导通常和模板类型推导相同,但是auto类型推导假定花括号初始化代表
  • std::initializer_list而模板类型推导不这样做 在C++14中auto允许出现在函数返回值或者lambda函数形参中,但是它的⼯作机制是模板类型推 导那⼀套⽅案。

item 3 理解decltype

1 使用它获取你想要的的类型

1
2
3
4
5
6
7
8
9
10
11
template<typename Container,typename Index> //C++ 14版本 
auto authAndAccess(Container& c,Index i)
{ authenticateUser(); return c[i]; }

std::deque<int> d;
...
authAndAccess(d,5)=10; //认证⽤⼾,返回d[5],然后把10赋值给它,⽆法通过编译器!
// --------------------
template<typename Container,typename Index> //最终的C++14版本
decltype(auto) authAndAccess(Container&& c,Index i)
{ authenticateUser(); return std::forward<Container>(c)[i]; }

上⾯的代码尝试把10赋值给右值,C++11禁⽌这样做,所以代码⽆法编译。
下面的改进版本,同时使用万能引用传入左值和右值的功能。

2 唯一需要注意的点

当使⽤decltype(auto) 的时候⼀定要加倍的小⼼,在表达式中看起来⽆⾜轻重的细节将会影响到类型的 推导。为了确认类型推导是否产出了你想要的结果,请参⻅Item4描述的那些技术。

1
2
3
4
5
6
7
8
9
int x;
// x是⼀个变量的名字,所以decltype(x) 是int。但是如果⽤⼀个小括号包覆这个名字,⽐如这样(x),
// 就会产⽣⼀个⽐名字更复杂的表达式。对于名字来说,x是⼀个左值,C++11定义了表达式(x) 则是⼀个左值。因此decltype((x)) 是int&


//decltype(x)是int,所以f1返回int
decltype(auto) f1() { int x = 0; ... return x; }
//decltype((x))是int&,所以f2返回int&
decltype(auto) f2() { int x =0l; return (x); }

rem

  • decltype总是不加修改的产⽣变量或者表达式的类型。
  • 对于T类型的左值表达式,decltype总是产出T的引⽤即T&。
  • C++14⽀持decltype(auto) ,就像auto⼀样,推导出类型,但是它使⽤⾃⼰的独特规则进⾏推 导。

item 4 学会看推导出来的类型

rem

  • 类型推断可以从IDE看出,从编译器报错看出,从⼀些库的使⽤看出
  • 这些⼯具可能既不准确也⽆帮助,所以理解C++类型推导规则才是最重要的

– chapter 2 –


item 5 优先考虑auto而不是显式类型

⾸先,深呼吸,放松,auto是可选项,不是命令,在某些情况下如果你的专业判断告诉你使⽤显式类型 声明⽐auto要更清晰更易维护,那你就不必再坚持使⽤auto。

1
2
3
4
5
6
7
8
9
10
std::unordered_map<std::string,int> m;
...
// 所以 std::pair 的类型不是 std::pair<std::string,int> 而是 std::pair<const std::string,int> 。
// 编译器会努⼒的找到⼀ 种⽅法把前者转换为后者。它会成功的,因为它会创建⼀个临时对象,这个临时对象的类
// 型是p想绑定到 的对象的类型,即m中元素的类型,然后把p的引⽤绑定到这个临时对象上。在每个循环迭代结束时,
// 临 时对象将会销毁,如果你写了这样的⼀个循环,你可能会对它的⼀些⾏为感到⾮常惊讶,因为你确信你 只是让
// 成为p指向m中各个元素的引⽤而已。
for(const std::pair<std::string,int>& p : m) { ... }
// 使⽤auto可以避免这些很难被意识到的类型不匹配的错误:
for(const auto & p : m) { ... }

rem

  • auto变量必须初始化,通常它可以避免⼀些移植性和效率性的问题,也使得重构更⽅便,还能让你 少打⼏个字。
  • 正如Item2和6讨论的,auto类型的变量可能会踩到⼀些陷阱。

item 6 若非己愿,用显示类型而不是auto

auto错误推到:

1
2
3
4
5
6
7
8
9
10
bool highPriority = features(w)[5]; //显式的声明highPriority的类型,√
auto highPriority = features(w)[5]; //推导highPriority的类型,×
// 调⽤feature将返回⼀个std::vector,这个对象没有名字,为了⽅便我们的讨论,我这⾥叫他temp,
// operator[] 被temp调⽤,然后然后的 std::vector<bool>::reference 包含⼀个指针,这个指针指
// 向⼀个temp⾥⾯的word,加上相应的偏移,。highPriority是⼀个 std::vector<bool>::reference
// 的拷⻉,所以highPriority也包含⼀个指针,指向temp中的⼀个word,加上合适的偏移,这⾥是5.在这个
// 语句解释的时候temp将会被销毁,因为它是⼀个临时变量。因此highPriority包含⼀个悬置的指针,
// 如 果⽤于processWidget调⽤中将会造成未定义⾏为:

processWidget(w,highPriority); //未定义⾏为! //highPriority包含⼀个悬置指针

作为⼀个通则,不可⻅的代理类通常不适⽤于auto。

当你不知道这个类型有没有被代理还想使⽤auto时你就不能单单只⽤⼀ 个auto。auto本⾝没什么问题,问题是auto不会推导出你想要的类型。
解决⽅案是强制使⽤⼀个不同的 类型推导形式,这种⽅法我通常称之为显式类型初始器惯⽤法(the explicitly typed initialized idiom)

1
auto highPriority = static_cast<bool>(features(w)[5]);

rem

  • 不可⻅的代理类可能会使auto从表达式中推导出“错误的”类型
  • 显式类型初始器惯⽤法强制auto推导出你想要的结果

— chapter 3 — cpp11/14新特性


item 7 区别使⽤()和{}创建对象

cpp11使用同一初始化,只有花括号任何地方都能用
仅仅阐述几个平时少见的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

// 不可拷⻉的对象可以使⽤花括号初始化或者小括号初始化,但是不能使⽤"="初始化
std::vector<int> ai1{0}; //没问题,x初始值为0
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!

// 括号表达式有⼀个异常的特性,它不允许内置类型隐式的变窄转换(narrowing conversion)
double x,y,z; int sum1{x+y+z}; //错误!三个double的和不能⽤来初始化int类型的变量

// 使⽤小括号和"="的初始化不检查是否转换为变窄转换,因为由于历史遗留问题它们必须要兼容⽼旧代码
int sum2(x + y +z); //可以(表达式的值被截为int)
int sum3 = x + y + z; //同上

// C++最令⼈头疼的解析也天⽣免疫
Widget w1(10); //使⽤实参10调⽤Widget的⼀个构造函数
Widget w2(); //最令⼈头疼的解析!声明⼀个函数w2,返回Widget
Widget w3{}; //调⽤没有参数的构造函数构造对象

但是你越喜欢⽤atuo,你就越不能⽤括号初始化,因为编译器热衷于把括号初始化与使
std::initializer_list构造函数匹配了,热衷程度甚⾄超过了最佳匹配,
甚⾄普通的构造函数和移动构造函数都会被std::initializer_list构造函数劫持:

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
35
class Widget { 
public:
Widget(int i, bool b); // 同上
Widget(int i, double d); // 同上
Widget(std::initializer_list<long double> il); //新添加的 …
};
Widget w1(10, true); // calls first ctor
Widget w2{10, true}; // uses braces, but now calls std::initializer_list ctor (10 and true convert to long double)
Widget w3(10, 5.0); // uses parens and, as before, calls second ctor
Widget w4{10, 5.0}; // uses braces, but now calls std::initializer_list ctor, (10 and 5.0 convert to long double)
Widget w5(w4); // 使⽤小括号,调⽤拷⻉构造函数
Widget w6{w4}; // 使⽤花括号,调⽤std::initializer_list构造函数
Widget w7(std::move(w4)); // 使⽤小括号,调⽤移动构造函数
Widget w8{std::move(w4)}; // 使⽤花括号,调⽤std::initializer_list构造函数

class Widget {
public:
Widget(int i, bool b); Widget(int i, double d);
Widget(std::initializer_list<bool> il); // element type is now bool … // no implicit conversion funcs };
Widget w{10, 5.0}; //错误!要求变窄转换

// 空的花括号意味着没有实参,不是⼀个空的std::initializer_list
class Widget {
public:
Widget();
Widget(std::initializer_list<int> il); ... };
Widget w1; // 调⽤默认构造函数 Widget
w2{}; // 同上
Widget w3(); // 最令⼈头疼的解析!声明⼀个函数
Widget w4({}); // 调⽤std::initializer_list Widget
w5{{}}; // 同上

// 最受影响的vector:
std::vector<int> v1(10, 20); //使⽤⾮std::initializer_list 构造函数创建⼀个包含10个元素的std::vector 所有的元素的值都是20
std::vector<int> v2{10, 20}; //使⽤std::initializer_list 构造函数创建包含两个元素的std::vector 元素的值为10和20

关于花括号和小括号的使⽤没有⼀个⼀致的观点,所以我的建议是⽤⼀个,并坚持使⽤。

rem

  • 括号初始化是最⼴泛使⽤的初始化语法,它防⽌变窄转换,并且对于C++最令⼈头疼的解析有天⽣ 的免疫性
  • 在构造函数重载决议中,括号初始化尽最⼤可能与std::initializer_list参数匹配,即便其他构造函数 看起来是更好的选择
  • 对于数值类型的std::vector来说使⽤花括号初始化和小括号初始化会造成巨⼤的不同
  • 在模板类选择使⽤小括号初始化或使⽤花括号初始化创建对象是⼀个挑战。

item 8 优先考虑nullptr而⾮0和NULL

nullptr的优点是它不是整型,同时也可以使代码表意明确,尤其是当和auto⼀起使⽤时。⽼实说它也不是⼀个指针类型,但是你可以把它认为是通⽤类型的指针。
nullptr的真正类型是std::nullptr_t,在⼀个完美的循环定义以后,std::nullptr_t⼜被定义为nullptr。
当模板出现时nullptr就更有⽤了。

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
int f1(std::shared_ptr<Widget> spw); // 只能被合适的 
double f2(std::unique_ptr<Widget> upw); // 已锁互斥量调
bool f3(Widget* pw);
std::mutex f1m, f2m, f3m; // 互斥量f1m,f2m,f3m,各种⽤于f1,f2,f3函数

// cpp14 封装一个模板lock/call/unlock的过程
template<typename FuncType,
typename MuxType,
typename PtrType>
decltype(auto) lockAndCall(
FuncType func, MuxType& mutex, PtrType ptr)
{
MuxGuard g(mutex);
return func(ptr);
}
// 在第⼀个调⽤中存在的问题是当0被传递给lockAndCall模板,模板类型推导会尝试去推导实参类型,
// 0的类型总是int,所以int版本的实例化中的func会被int类型的实参调⽤。 这与f1期待的参数std::shared_ptr不符。
auto result1 = lockAndCall(f1, f1m, 0); // 错误! …

// 第⼆个使⽤NULL调⽤的分析也是⼀样的。当NULL被传递给lockAndCall,形参ptr被推导为整型(可以是long or int等等,具体看编译器),
// 然后当ptr——⼀个int或者类似int的类型——传递给f2的时候就会出现类型错误。当ptr被传递给f3的时 候,
auto result2 = lockAndCall(f2, f2m, NULL); // 错误! …

// 隐式转换使std::nullptr_t转换为Widget* ,因为std::nullptr_t可以隐式转换为任何指针类型。
auto result3 = lockAndCall(f3, f3m, nullptr); // 没问题

rem

  • 优先考虑nullptr而⾮0和NULL
  • 避免重载指针和整型(因为在cpp98中,绝对会把0当成int,而你用0当成空指针指望着去调用重载的指针函数,你会失败)

item 9 优先考虑别名声明而⾮typedef

简单例子:

1
2
3
4
// FP是⼀个指向函数的指针的同义词,它指向的函数带有int和const std::string&形参,不返回任何东 西
typedef void (*FP)(int, const std::string&); // typedef
//同上
using FP = void (*)(int, const std::string&); // 别名声明

不过有⼀个地⽅使⽤别名声明吸引⼈的理由是存在的:模板。特别的,别名声明可以被模板化但是typedef不行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// c89 只能把typedef嵌套进模板化的struct才能表 达的东西
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<int>::type lw;

template<typename T>
class Widget {
private:
// 这⾥MyAllocList::type使⽤了⼀个类型,这个类型依赖于模板参数T。 因此MyAllocList::type是⼀个依赖类型,在C++很多讨⼈喜欢的规则中的⼀个提到必须要在依赖类型名 前加上typename。
// cpp标准:对于用于模板定义的依赖于模板参数的名称,只有在实例化的参数中存在这个类型名,或者这个名称前使用了typename关键字来修饰,编译器才会将该名称当成是类型。除了以上这两种情况,绝不会被当成是类型。
typename MyAllocList<T>::type list;

};
// c11 就直接多了
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
template<typename T>
class Widget {
private:
MyAllocList<T> list;

};

如果你尝试过模板元编程(TMP), 你⼀定会碰到取模板类型参数然后基于它创建另⼀种类型的情况。 举个例⼦,给⼀个类型T,
如果你想去掉T的常量修饰和引⽤修饰,⽐如你想把const std::string&变成const std::string。尽管写了⼀些,但我这⾥不是想给你⼀个关于type traits使⽤的教程。注意类型转换尾部的::type。 如果你在⼀个模板内部使⽤类型参数,你也需要在它们前⾯加上typename。
因为标准委员会没有及时 认识到别名声明是更好的选择,所以直到C++14它们才提供了使⽤别名声明的版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::remove_const<T>::type // C++11: const T → T 
std::remove_const_t<T> // C++14 等价形式
std::remove_reference<T>::type // C++11: T&/T&& → T
std::remove_reference_t<T> // C++14 等价形式
std::add_lvalue_reference<T>::type // C++11: T → T&
std::add_lvalue_reference_t<T> // C++14 等价形式

// 如果你有cpp14,然后手动实现从11到14的转变:
template <class T>
using remove_const_t = typename remove_const<T>::type;

template <class T>
using remove_reference_t = typename remove_reference<T>::type;

template <class T>
using add_lvalue_reference_t = typename add_lvalue_reference<T>::type;

rem

  • typedef不⽀持模板化,但是别名声明⽀持。
  • 别名模板避免了使⽤”::type”后缀,而且在模板中使⽤typedef还需要在前⾯加上typename
  • C++14提供了C++11所有类型转换的别名声明版本

item 10 优先考虑限域枚举而⾮未限域枚举

限域枚举相比于非限域枚举的优点如下:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 优点1: 限域枚举(scoped enum),它不会导致枚举名泄漏
// 非限域枚举
enum Color { black, white, red }; // black, white, red 和 // Color⼀样都在相同作⽤域
auto white = false; // 错误! white早已在这个作⽤ // 域中存在

// 限域枚举
enum class Color { black, white, red }; // black, white, red // 限制在Color域内
auto white = false; // 没问题,同样域内没有这个名字
Color c = white; //错误,这个域中没有white
Color c = Color::white; // 没问题
auto c = Color::white; // 也没问题(也符合条款5的建议)

// 优点2:在不存在任何隐式转换可以将限域枚举中的枚举名转化为任何其他类型,也就是拒绝隐式转换
enum Color { black, white, red }; // 未限域枚举
std::vector<std::size_t> // func返回x的质因⼦
primeFactors(std::size_t x);
Color c = red;

if (c < 14.5) { // Color与double⽐较
auto factors = // 计算⼀个Color的质因⼦(!)
primeFactors(c);

}

enum class Color { black, white, red }; // Color现在是限域枚举
Color c = Color::red; // 和之前⼀样,只是 多了⼀个域修饰符

if (c < 14.5) { // 错误!不能⽐较Color和double
auto factors = // 错误! 不能向参数为std::size_t的函数
primeFactors(c); // 传递Color参数

}

// 若真的非常想,需要用类型转化如下:
if (static_cast<double>(c) < 14.5) { // 奇怪的代码,但是有效
auto factors = // suspect, but primeFactors
(static_cast<std::size_t>(c)); // 能通过编译

}

// 为了⾼效使⽤内存,编译器通常在确保能包含所有枚举值的前提下为枚举选择⼀个最小的基础类型。在 ⼀些情况下,
// 编译器 将会优化速度,舍弃⼤小,这种情况下它可能不会选择最小的基础类型,而是选择对优化⼤小有帮助的 类型。为此,C++98
// 只⽀持枚举定义(所有枚举名全部列出来),枚举声明是不被允许的。这使得编译器能为之前使⽤的每 ⼀个枚举选择⼀个基础类型。但这样不能前置申明的缺点很明显,会增加编译依赖,当enum新增一个状态,所有用了这个enum的,都必须重新编译,而cpp11会解决这个问题
// 优点3:限域枚举可以前置声明,但是目前google cpp style推荐使用include
enum class Status {
good = 0, failed = 1, incomplete = 100, corrupt = 200, audited = 500, indeterminate = 0xFFFFFFFF
};

enum class Status; // forward declaration void
continueProcessing(Status s); // use of fwd-declared enum

// 获取enum对应的数字,使用模板
using UserInfo = // 类型别名,参⻅Item 9
std::tuple<std::string, // 名字
std::string, // email地址
std::size_t> ; // 声望

// 需要用static_cast强制转化到size_t,但是
enum class UserInfoFields { uiName, uiEmail, uiReputation }; UserInfo uInfo; // as before

auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)> (uInfo); // 很啰嗦
// 用模板爽快点
template<typename E> // C++14
constexpr auto toUType(E enumerator) noexcept
{ return static_cast<std::underlying_type_t<E>>(enumerator); }

auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

rem

  • C++98的枚举即⾮限域枚举
  • 限域枚举的枚举名仅在enum内可⻅。要转换为其它类型只能使⽤cast。
  • ⾮限域/限域枚举都⽀持基础类型说明语法,限域枚举基础类型默认是 int,⾮限域枚举没有默认 基础类型。
  • 限域枚举总是可以前置声明。⾮限域枚举仅当指定它们的基础类型时才能前置。

item 11 优先考虑使⽤deleted函数而⾮使⽤未定义的私有声明

你想要禁止客户使用某些函数代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// deleted 函数不能以任何⽅式被调⽤,即使你在成员函数或者友元函数⾥⾯调⽤ deleted 函数也不能通过编译。
template <class charT, class traits = char_traits<charT> > class basic_ios : public ios_base {
public:

basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;

};
// 强制只有传入int才对
bool isLucky(int number); // 原始版本
bool isLucky(char) = delete; // 拒绝char
bool isLucky(bool) = delete; // 拒绝
bool bool isLucky(double) = delete; // 拒绝float和double

禁止一些模板实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// eg1: 
template<typename T> void processPointer(T* ptr);
template<> void processPointer<void>(void*) = delete; template<> void processPointer<char>(char*) = delete;
template<> void processPointer<const void>(const void*) = delete;
template<> void processPointer<const char>(const char*) = delete;
// eg2:
class Widget {
public:

template<typename T> void processPointer(T* ptr) { … }

private:
template<> // 错误!因为不能给特化的模板函数指定⼀个不同(于函数模板)的访问级别,cpp98的方案已经不行了
void processPointer<void>(void*);
};

class Widget {
public:

template<typename T> void processPointer(T* ptr) { … }

};
template<> void Widget::processPointer<void>(void*) = delete; // 还是public,但是已经被删除了

rem

  • ⽐起声明函数为private但不定义,使⽤delete函数更好
  • 任何函数都能 delete ,包括⾮成员函数和模板实例

item 12 使⽤override声明重载函数

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
35
36
class Base { public:
// 成员函数引⽤限定(reference qualifiers)
void doWork() &; // 只有*this为左值的时候才能被调⽤
void doWork() &&; // 只有*this为右值的时候才能被调⽤
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};

class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override; //
void mf4() const override;
};

// 上述的错误在于: mf1 在基类声明为 const ,但是派⽣类没有这个常量限定符 mf2 在基类声明为接受⼀个 int 参数,但是在派⽣类声明为接受 unsigned
// int 参数 mf3 在基类声明为左值引⽤限定,但是在派⽣类声明为右值引⽤限定 mf4 在基类没有声明为虚函数
// ⽐起让编译器(译注:通过warnings)告诉你"将要"重写实际不会重写,不如给你的派⽣类成员函数全 都加上 override
// 如果你考虑修改修改基类虚函数的函数签名, override 还可以帮你评估后果,对于 override ,它 只在成员函数声明结尾处才被视为关键字。

// 成员函数的引用限定
class Widget {
public:
using DataType = std::vector<double>; …
DataType& data() & // 对于左值Widgets,
{ return values; } // 返回左值
DataType data() && // 对于右值Widgets,
{ return std::move(values); } // 返回右值 …
private: DataType values;
};

auto vals1 = w.data(); //调⽤左值重载版本的Widget::data,拷⻉构造vals1
auto vals2 = makeWidget().data(); //调⽤右值重载版本的Widget::data, 移动构造vals2,否则.data()返回一个临时右值,进行拷贝纯属浪费

rem

  • 为重载函数加上 override
  • 成员函数限定让我们可以区别对待左值对象和右值对象(即 *this )

item 13 优先考虑const_iterator而⾮iterator

rem

  • 优先考虑const_iterator而⾮iterator
  • 在最⼤程度通⽤的代码中,优先考虑⾮成员函数版本的begin,end,rbegin等,而⾮同名成员函 数(因为c11并没有cbegin,那么你可以手动写一个cbegin)

item 14 如果函数不抛出异常请使⽤noexcept

函数是否为noexcept和成员函数是否const⼀样重要。如果知道这个函数不会抛异常就 加上noexcept是简单天真的接口说明。
不过这⾥还有给不抛异常的函数加上noexcept的动机:它允许编译器⽣成更好的⽬标代码。

1
2
3
RetType function(params) noexcept; // 极尽所能优化 cpp11
RetType function(params) throw(); // 较少优化 cpp98
RetType function(params); // 较少优化

希望你能为noexcept提供的优化机会感到⾼兴,同时我还得让你缓⼀缓别太⾼兴了。优化很 重要,但是正确性更重要。些函数很⾃然的不应该抛异常,
更进⼀步值得注意的是移动操作和swap——使其不抛异常有重 ⼤意义,只要可能就应该将它们声明为noexcept。或者像是vector的push_back在发生扩容(能移动就移
动,必要时就复制)的时候,最后一个移动产生异常了,那么导致push_back失败,然后移动了的vec也不容易返回到原来的位置,

rem

  • 在C++98构造函数和析构函数抛出 异常是糟糕的代码设计
  • noexcept是函数接口的⼀部分,这意味着调⽤者会依赖它、
  • noexcept函数较之于⾮noexcept函数更容易优化
  • noexcept对于移动语义,swap,内存释放函数和析构函数⾮常有⽤ ⼤多数函数是异常中⽴的(译注:可能抛也可能不抛异常)而不是noexcept

item 15 尽可能的使⽤constexpr

constexpr表明⼀个值不仅仅是常量,还是编译期可知的。这个表述并不全⾯,因为当constexpr被⽤于函数的时候,事情就有⼀些细微差别了。
你不能假设constexpr函数是const,也不能保证 它们的(译注:返回)值是在编译期可知的。最有意思的是,这些是特性。关于constexpr函数返回的
结果不需要是const,也不需要编译期可知这⼀点是良好的⾏为。

1
2
3
4
5
6
7
8
9
10
int sz; // ⾮constexpr变量 

constexpr auto arraySize1 = sz; // 错误! sz的值在 // 编译期不可知
std::array<int, sz> data1; // 错误!⼀样的问题
constexpr auto arraySize2 = 10; // 没问题,10是编译 // 期可知常量
std::array<int, arraySize2> data2; // 没问题, arraySize2是constexpr

// 如果你想在这些 context 中使⽤变量,你⼀定会希望将它们声明为constexpr,因为编译器会确保它们是编译期可知 的:
int sz; // 和之前⼀样 const auto arraySize = sz; // 没问题,arraySize是sz的常量复制
std::array<int, arraySize> data; // 错误,arraySize值在编译期不可知

constexpr相当于宣称“我能在C++要求常量表达式的地⽅使⽤它”

rem

  • constexpr对象是cosnt,它的值在编译期可知 当传递编译期可知的值时,
  • cosntexpr函数可以产出编译期可知的结果(越多这样的代码,运行时就会少一些不必要的计算,你的代码就越快)

item 16 让const成员函数线程安全

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
if (!rootsAreValid) { //如果缓存不可用
//计算根
//用rootVals存储它们
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false }; //初始化器(initializer)的
mutable RootsType rootVals{}; //更多信息请查看条款7
};

// 假设现在有两个线程同时调⽤ Polynomial 对象的 roots ⽅法:
/*------ Thread 1 ------*/ /*-------- Thread 2 --------*/
auto rootsOfp = p.roots(); auto valsGivingZero = p.roots();

// 意味 着在没有同步的情况下,这些代码会有不同的线程读写相同的内存,这就是 data race 的定义。这段代 码的⾏为是未定义的。

// ⼀旦你需要对两个以上的变量或内存位置作为⼀个单元来操作的话,就应该使⽤互斥锁
// const成员函数应⽀持并发执⾏,这就是为什么你应该确保const成员函数是线程安 全的
class Widget {
public:

int magicValue() const
{
std::lock_guard<std::mutex> guard(m); //锁定m

if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} //解锁m


private:
mutable std::mutex m;
mutable int cachedValue; //不再用atomic
mutable bool cacheValid{ false }; //不再用atomic
};

rem

  • 确保const成员函数线程安全,除⾮你确定它们永远不会在临界区(concurrent context)中 使⽤。
  • std::atomic 可能⽐互斥锁提供更好的性能,但是它只适合操作单个变量或内存位置。

item 17 理解特殊成员函数的⽣成

C++11特殊成员函数俱乐部迎来了两位新会员:移动构造函数和移动赋值运算符。
先简单记住如果⽀持移动就会逐成员移动类成员和基类成 员,如果不⽀持移动就执⾏拷⻉操作就好了,item23会更新!

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
35
36
37
38
39
40
41
42
43
44
45
46
47
// 默认生成的6个
class Widget {
public:

Widget();
~Widget();
Widget(Widget& w);
Widget& operator=(Widget& rhs);
Widget(Widget&& rhs); //移动构造函数
Widget& operator=(Widget&& rhs); //移动赋值运算符

};
// 如果你声明了某个移动函数,编译器就不再⽣成另⼀个移动函数。这与复制函数的⽣成规则不太⼀样: 两个复制函数是独⽴的,声明⼀个不会影响另⼀个的默认⽣成。
// 如果你声明了 某个移动函数,就表明这个类型的移动操作不再是“逐⼀移动成员变量”的语义,即你不需要编译器默认 ⽣成的移动函数的语义,因此编译器也不会为你⽣成另⼀个移动函数。
// 再进⼀步,如果⼀个类显式声明了拷⻉操作,编译器就不会⽣成移动操作。这种限制的解释是如果声明 拷⻉操作就暗⽰着默认逐成员拷⻉操作不适⽤于该类,编译器会明⽩如果默认拷⻉不适⽤于该类,移动 操作也可能是不适⽤的。
// 声明移动操作使得编译器不会⽣成拷⻉操作。

// Rule of Three规则。这个规则告诉我们如果你声明了拷⻉构造函数,拷⻉赋值运算符, 或者析构函数三者之⼀,你应该也声明其余两个
// Rule of Three规则背后的解释依然有效,再加上对声明拷⻉操作阻⽌移动操作隐式⽣成的观察,使得C++11不会为那些有⽤⼾定义的析构函数的类⽣成移动操作。

// 但是你显示的声明了你的析构函数,又想用默认的拷贝
class Widget {
public:

~Widget(); //用户声明的析构函数
//默认拷贝构造函数
Widget(const Widget&) = default; //的行为还可以

Widget& //默认拷贝赋值运算符
operator=(const Widget&) = default; //的行为还可以

};

class StringTable {
public:
StringTable()
{ makeLogEntry("Creating StringTable object"); } //增加的

~StringTable() //也是增加的
{ makeLogEntry("Destroying StringTable object"); }
//其他函数同之前一样
private:
std::map<int, std::string> values; //同之前一样
};
// 看起来合情合理,但是声明析构有潜在的副作⽤:它阻⽌了移动操作的⽣成。然而,拷⻉操作的⽣成是 不受影响的。因此代码能通过编译,运⾏,也能通过功能(译注:即打⽇志的功能)测试。功能测试也 包括移动功能,因为即使该类不⽀持移动操作,对该类的移动请求也能通过编译和运⾏。这个请求正如 之前提到的,会转而由拷⻉操作完成。它因为着对StringTable对象的移动实际上是对对象的拷⻉,即 拷⻉⾥⾯的 std::map<int, std::string> 对象。拷⻉ std::map<int, std::string> 对象很可能⽐ 移动慢⼏个数量级。简单的加个析构就引⼊了极⼤的性能问题!对拷⻉和移动操作显式加个 =default ,问题将不再出现。

// 注意没有成员函数模版阻⽌编译器⽣成特殊成员函数的规则

rem

  • 特殊成员函数是编译器可能⾃动⽣成的函数:默认构造,析构,拷⻉操作,移动操作。
  • 移动操作仅当类没有显式声明移动操作,拷⻉操作,析构时才⾃动⽣成。
  • 拷⻉构造仅当类没有显式声明拷⻉构造时才⾃动⽣成,并且如果⽤⼾声明了移动操作,拷⻉构造就 是delete。
  • 拷⻉赋值运算符仅当类没有显式声明拷⻉赋值运算符时才⾃动⽣成,并且如果⽤⼾声明 了移动操作,拷⻉赋值运算符就是delete。当⽤⼾声明了析构函数,拷⻉操作不再⾃动⽣成。

—- chapter 4 —- 智能指针

item 18 对于独占资源使⽤std::unique_ptr

如果原始指针够小够快,那么 std::unique_ptr ⼀样可以。可以移动但是不允许拷贝,不然每个都认为⾃⼰拥有资源,销 毁时就会出现重复销毁。
且unique_ptr适用于工厂函数返回新产生的对象的指针,因为我们并不知道产生的新对象是需要被专有化还是共享,unique_ptr转到shared_ptr非常方便。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class Investment { ... }; 
class Sock: public Investment {...};
class Bond: public Investment {...};
class RealEstate: public Investment {...};

template<typename... Ts> //返回指向对象的std::unique_ptr,
std::unique_ptr<Investment> //对象使用给定实参创建
makeInvestment(Ts&&... params);
// 调用注意,使用{}做好生命周期控制
{

auto pInvestment = //pInvestment是
makeInvestment( arguments ); //std::unique_ptr<Investment>类型

} //销毁 *pInvestment

// 可以自定义unique_ptr退出作用域时刻调用的析构函数
auto delInvmt = [](Investment* pInvestment) //自定义删除器
{ //(lambda表达式)
makeLogEntry(pInvestment);
delete pInvestment;
};

template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> //更改后的返回类型
makeInvestment(Ts&&... params)
{
std::unique_ptr<Investment, decltype(delInvmt)> //应返回的指针
pInv(nullptr, delInvmt);
if (/*一个Stock对象应被创建*/)
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /*一个Bond对象应被创建*/ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /*一个RealEstate对象应被创建*/ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
// 自定义的几个注意
// 1 当使⽤⾃定义删除器时,必须将其作为第⼆个参数传给 std::unique_ptr
// 2 尝试将原始指针(⽐如new创建)赋值给 std::unique_ptr 通不过编译,因为不存在从原始指针 到智能指针的隐式转换。这种隐式转换会出问题,所以禁⽌。这就是为什么通过 reset 来传递new指针的原因]
// 3 使⽤new时,要使⽤ std::forward 作为参数来完美转发给 makeInvestment
// 4 ⾃定义删除器的参数类型是 Investment* ,尽管真实的对象类型是在 makeInvestment 内部创建 的,它最终通过在lambda表达式中,作为 Investment* 对象被删除。这意味着我们通过基类指针删除派⽣类实例,为此,基类必须是虚函数析构

// 由于cpp14能够推断auto,所以可以使用更加简单的方式书写:
template<typename... Ts>
auto makeInvestment(Ts&&... params) // C++14
{
auto delInvmt = [](Investment* pInvestment) // this is now
{ // inside
makeLogEntry(pInvestment); // make-
delete pInvestment; // Investment
};
... // as before
}

// 当使⽤默认删除器时,可以合理假设 std::unique_ptr 和原始指针⼤小相同。当⾃定义删除器时,删除器是个函数指针,通常会使 std::unique_ptr 的字节从⼀个增加到 两个。对于删除器的函数对象来说,⼤小取决于函数对象中存储的状态多少,⽆状态函数对象(⽐如没 有捕获的lambda表达式)对⼤小没有影响,这意味当⾃定义删除器可以被lambda实现时,尽量使⽤lambda

auto delInvmt1 = [](Investment* pInvestment) //无状态lambda的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, decltype(delInvmt1)> //Investment*的大小
makeInvestment(Ts&&... args);
// ---
void delInvmt2(Investment* pInvestment) //函数形式的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
}
template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, void (*)(Investment*)> //Investment*的指针
makeInvestment(Ts&&... params); //加至少一个函数指针的大小

// std::unique_ptr 有两种形式,⼀种⽤于单个对象( std::unique_ptr<T> ),⼀种⽤于数组 ( std::unique_ptr<T[]> )。
// 数组的std::unique_ptr不应该被使用,因为有std::array等等去取代

// std::unique_ptr 是C++11中表⽰专有所有权的⽅法,但是其最吸引⼈的功能之⼀是它可以轻松⾼效的 转换为 std::shared_ptr :
std::shared_ptr<Investment> sp = makeInvestment(arguments);
// 这就是为什么 std::unique_ptr ⾮常适合⽤作⼯⼚函数返回类型的关键部分

rem

  • std::unique_ptr 是轻量级、快速的、只能move的管理专有所有权语义资源的智能指针
  • 默认情况,资源销毁通过delete,但是⽀持⾃定义delete函数。有状态的删除器和函数指针会增加
  • std::unique_ptr 的⼤小 将 std::unique_ptr 转化为 std::shared_ptr 是简单的

item 19 对于共享资源使⽤std::shared_ptr

std::shared_ptr 通过引⽤计数来确保它是否是最后⼀个指向某种资源的指针,引⽤计数关联资源并跟 踪有多少 std::shared_ptr 指向该资源。 std::shared_ptr 构造函数递增引⽤计数值(注意是通常,原因是移动构造函数的存在。从另⼀个 std::shared_ptr 移动构造新 std::shared_ptr 会将原来的 std::shared_ptr 设置为null,那意味着⽼的 std::shared_ptr 不再指向资源,同时新的 std::shared_ptr 指向资源。这样的结果就是不需要修改引⽤计数值。因此移动 std::shared_ptr 会 ⽐拷⻉它要快:拷⻉要求递增引⽤计数值,移动不需要。移动赋值运算符同理,所以移动赋值运算符也 ⽐拷⻉赋值运算符快。),析构函数递减值,拷⻉赋值运算符可能递增也可能递减值。(如果sp1和sp2是 std::shared_ptr 并且指向不同对象,赋值运算符 sp1=sp2 会使sp1指向sp2指向的对象。直接效果就 是sp1引⽤计数减⼀,sp2引⽤计数加⼀。)
引⽤计数暗⽰着性能问题:

  • std::shared_ptr ⼤小是原始指针的两倍,因为它内部包含⼀个指向资源的原始指针,还包含⼀ 个资源的引⽤计数值。
  • 引⽤计数必须动态分配。 理论上,引⽤计数与所指对象关联起来,但是被指向的对象不知道这件事情(译注:不知道有指向⾃⼰的指针)。因此它们没有办法存放⼀个引⽤计数值。Item21会解释使 ⽤ std::make_shared 创建 std::shared_ptr 可以避免引⽤计数的动态分配,但是还存在⼀些 std::make_shared 不能使⽤的场景,这时候引⽤计数就会动态分配。
  • 递增递减引⽤计数必须是原⼦性的,因为多个reader、writer可能在不同的线程。⽐如,指向某种 资源的 std::shared_ptr 可能在⼀个线程执⾏析构,在另⼀个不同的线程, std::shared_ptr 指 向相同的对象,但是执⾏的确是拷⻉操作。原⼦操作通常⽐⾮原⼦操作要慢,所以即使是引⽤计 数,你也应该假定读写它们是存在开销的。
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
auto loggingDel = [](Widget *pw)        //自定义删除器
{ //(和条款18一样)
makeLogEntry(pw);
delete pw;
};

std::unique_ptr< //删除器类型是
Widget, decltype(loggingDel) //指针类型的一部分
> upw(new Widget, loggingDel);
std::shared_ptr<Widget> //删除器类型不是
spw(new Widget, loggingDel); //指针类型的一部分


// 区别于std::unique_ptr的2点不同:
// 对于 std::unique_ptr 来说,销毁器类型是智能指针类型的⼀部分。对于 std::shared_ptr 则不是,std::shared_ptr 的设计更为灵活。考虑有两个 std::shared_ptr ,每个⾃带不同的销毁器(⽐如通 过lambda表达式⾃定义销毁器):

auto customDeleter1 = [](Widget *pw) { … }; //自定义删除器,
auto customDeleter2 = [](Widget *pw) { … }; //每种类型不同
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
// 因为pw1和pw2有相同的类型,所以它们都可以放到存放那个类型的对象的容器中,它们也能相互赋值,也可以传⼊形参为 std::shared_ptr<Widget> 的函数。但是 std::unique_ptr 就 不⾏,因为 std::unique_ptr 把销毁器视作类型的⼀部分。
// 另⼀个不同于 std::unique_ptr 的地⽅是,指定⾃定义销毁器不会改变 std::shared_ptr 对象的⼤ 小。不管销毁器是什么,⼀个 std::shared_ptr 对象都是两个指针⼤小。

// std::shared_ptr 怎么能引⽤⼀个任意⼤的销毁器而不使⽤更多的内存? 它不能。它必须使⽤更多的内存。然而,那部分内存不是 std::shared_ptr 对象的⼀部分。那部分在堆 上⾯,只要 std::shared_ptr ⾃定义了分配器,那部分内存随便在哪都⾏。
// 为引⽤计数是另⼀个更 ⼤的数据结构的⼀部分,那个数据结构通常叫做控制块(control block)。控制块包含除了引⽤计数值 外的⼀个⾃定义销毁器的拷⻉,当然前提是存在⾃定义销毁器。如果⽤⼾还指定了⾃定义分配器,控制 器也会包含⼀个分配器的拷⻉。控制块可能还包含⼀些额外的数据,正如Item21提到的,⼀个次级引⽤ 计数weak count,控制块的创建会遵循下⾯⼏条规则:
// 1 std::make_shared 总是创建⼀个控制块(参⻅Item21)。它创建⼀个指向新对象的指针,所以可以 肯定 std::make_shared 调⽤时对象不存在其他控制块。
// 2 当从独占指针上构造出 std::shared_ptr 时会创建控制块(即 std::unique_ptr 或者 std::auto_ptr )。独占指针没有使⽤控制块,所以指针指向的对象没有关联其他控制块。(作 为构造的⼀部分, std::shared_ptr 侵占独占指针所指向的对象的独占权,所以 std::unique_ptr 被设置为null)
// 3 当从原始指针上构造出 std::shared_ptr 时会创建控制块。如果你想从⼀个早已存在控制块的对 象上创建 std::shared_ptr ,你将假定传递⼀个 std::shared_ptr 或者 std::weak_ptr 作为构 造函数实参,而不是原始指针。⽤ std::shared_ptr 或者 std::weak_ptr 作为构造函数实参创 建 std::shared_ptr 不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。

// 这些规则造成的后果就是从原始指针上构造超过⼀个 std::shared_ptr 就会让你走上未定义⾏为的快⻋ 道,因为指向的对象有多个控制块关联。多个控制块意味着多个引⽤计数值,多个引⽤计数值意味着对 象将会被销毁多次(每个引⽤计数⼀次)。那意味着下⾯的代码是有问题的,很有问题,问题很⼤:
auto pw = new Widget; //pw是原始指针
std::shared_ptr<Widget> spw1(pw, loggingDel); //为*pw创建控制块
std::shared_ptr<Widget> spw2(pw, loggingDel); //为*pw创建第二个控制块
// 但是将同样的原始指针传递给spw2的构 造函数会再次为 *pw 创建⼀个控制块。因此 *pw 有两个引⽤计数值,每⼀个最后都会变成零,然后最终 导致 *pw 销毁两次。第⼆个销毁会产⽣未定义⾏为。
// std::shared_ptr 给我们上了两堂课。
// 1 第⼀,避免传给 std::shared_ptr 构造函数原始指针。通常替 代⽅案是使⽤ std::make_shared (参⻅Item21),不过上⾯例⼦中,我们使⽤了⾃定义销毁器,⽤ std::make_shared 就没办法做到。
// 2 第⼆,如果你必须传给 std::shared_ptr 构造函数原始指针,直 接传new出来的结果,不要传指针变量。如果上⾯代码第⼀部分这样重写:
std::shared_ptr<Widget> spw1(new Widget, //直接使用new的结果
loggingDel);
std::shared_ptr<Widget> spw2(spw1); // spw2使⽤spw1⼀样的控制块

// shared_ptr指向this发生的错误:
class Widget { public: …void process(); … };
void Widget::process() { … // 处理Widget
processedWidgets.emplace_back(this); // 然后将他加到已处理过的Widget的列表中
// 这是错的 ,不是由于emplace_back
}
// 上⾯的代码可以通过编译,但是向容 器传递⼀个原始指针(this), std::shared_ptr 会由此为指向的对象( *this )创建⼀个控制块。那 看起来没什么问题,直到你意识到如果成员函数外⾯早已存在指向Widget对象的指针,它是未定义⾏为 的,std::enable_shared_from_this 就是用来处理它的,这个标准名字就是奇异递归模板模式(TheCuriously Recurring Template Pattern(CRTP))。

// ⽆论在哪当你想使 ⽤ std::shared_ptr 指向this所指对象时都请使⽤它。这
class Widget: public std::enable_shared_from_this<Widget> {
public:

void process();

};
void Widget::process()
{
//和之前一样,处理Widget

//把指向当前对象的std::shared_ptr加入processedWidgets
processedWidgets.emplace_back(shared_from_this());
}
// 从内部来说, shared_from_this 查找当前对象控制块,然后创建⼀个新的 std::shared_ptr 指向这 个控制块。设计的依据是当前对象已经存在⼀个关联的控制块。要想符合设计依据的情况,必须已经存 在⼀个指向当前对象的 std::shared_ptr (即调⽤shared_from_this的成员函数外⾯已经存在⼀个std::shared_ptr )。如果没有 std::shared_ptr 指向当前对象(即当前对象没有关联控制块),⾏为 是未定义的,shared_from_this通常抛出⼀个异常。

// 再次讨论大小:
// 控制块通常只占⼏个word⼤小,⾃定义销毁器和分配器可能会让它变⼤⼀点。通常控制块的实现⽐你想 的更复杂⼀些。它使⽤继承,甚⾄⾥⾯还有⼀个虚函数(⽤来确保指向的对象被正确销毁)。这意味着 使⽤ std::shared_ptr 还会招致控制块使⽤虚函数带来的成本。
// 在通常情况下, std::shared_ptr 创建控制块会使⽤默认销毁器和默认分配器,控制块只需三个word⼤小。它的分配基本上是⽆开销的。对 std::shared_ptr 解引⽤的开销不 会⽐原始指针⾼。执⾏原⼦引⽤计数修改操作需要承担⼀两个原⼦操作开销,这些操作通常都会⼀⼀映 射到机器指令上,所以即使对⽐⾮原⼦指令来说,原⼦指令开销较⼤,但是它们仍然只是单个指令。对 于每个被 std::shared_ptr 指向的对象来说,控制块中的虚函数机制产⽣的开销通常只需要承受⼀次, 即对象销毁的时候。

// std::shared_ptr 不能处理的另⼀个东西是数组。和 std::unique_ptr 不同的是, std::shared_ptr 的API设计之初就是针对单个对象的,没有办法 std::shared_ptr<T[]>

rem

  • std::shared_ptr 为任意共享所有权的资源⼀种⾃动垃圾回收的便捷⽅式。
  • 较之于 std::unique_ptr , std::shared_ptr 对象通常⼤两倍,控制块会产⽣开销,需要原⼦引 ⽤计数修改操作。
  • 默认资源销毁是通过delete,但是也⽀持⾃定义销毁器。销毁器的类型是什么对于 std::shared_ptr 的类型没有影响。
  • 避免从原始指针变量上创建 std::shared_ptr 。

item 20 当std::shard_ptr可能悬空时使⽤std::weak_ptr

⼀个真正的智能指针应该跟踪所值 对象,在悬空时知晓,悬空(dangle)就是指针指向的对象不再存在。这就是对 std::weak_ptr 最精确的 描述。

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
35
36
37
38
39
40
// std::weak_ptr 通常从 std::shared_ptr 上创建。当从 std::shared_ptr 上创建 std::weak_ptr 时两者指向相同的对象,但是 std::weak_ptr 不会影响所指 对象的引⽤计数:

auto spw = //spw创建之后,指向的Widget的
std::make_shared<Widget>(); //引用计数(ref count,RC)为1。
//std::make_shared的信息参见条款21

std::weak_ptr<Widget> wpw(spw); //wpw指向与spw所指相同的Widget。RC仍为1

spw = nullptr; //RC变为0,Widget被销毁。
//wpw现在悬空
if (wpw.expired()) … // if wpw doesn't point to an object


// 从weak_ptr创建shared_ptr
std::shared_ptr<Widget> spw1 = wpw.lock(); // if wpw's expired, spw1 is null
auto spw2 = wpw.lock(); // same as above, but uses auto
std::shared_ptr<Widget> spw3(wpw); // if wpw's expired, throw std::bad_weak_ptr

// 缓存应该使⽤ std::weak_ptr ,这可以知道是否已经悬空。这意味着⼯⼚函数返回 值类型应该是 std::shared_ptr ,因为只有当对象的⽣命周期由 std::shared_ptr 管理时, std::weak_ptr 才能检测到悬空。
std::unique_ptr<const Widget> loadWidget(WidgetID id);

std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache;
//译者注:这里std::weak_ptr<const Widget>是高亮
auto objPtr = cache[id].lock(); //objPtr是去缓存对象的
//std::shared_ptr(或
//当对象不在缓存中时为null)

if (!objPtr) { //如果不在缓存中
objPtr = loadWidget(id); //加载它
cache[id] = objPtr; //缓存它
}
return objPtr;
}

// A有B,B有A,那么A中B,B中A都得用weak_ptr,否则销毁时出现循环,导致资源泄露
// 我写的是 std::weak_ptr 不参与对象的共享所有 权,因此不影响指向对象的引⽤计数。

rem

  • 像 std::shared_ptr 使⽤ std::weak_ptr 可能会悬空。
  • std::weak_ptr 的潜在使⽤场景包括:caching、observer lists、打破 std::shared_ptr 指向循 环。

item 21 优先考虑使⽤std::make_unique和std::make_shared而⾮new

本item的意⻅是,更倾向于使⽤make函数,而不是完全依赖于它们。这是因为有些情况下 它们不能或不应该被使⽤。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 1 重复写类型和软件⼯程⾥ ⾯⼀个关键原则相冲突:应该避免重复代码。源代码中的重复增加了编译的时间,会导致⽬标代码冗 余,并且通常会让代码库使⽤更加困难。它经常演变成不⼀致的代码,而代码库中的不⼀致常常导致bug。此外,打两次字⽐⼀次更费⼒,而且谁不喜欢减少打字负担

auto upw1(std::make_unique<Widget>()); //使用make函数
std::unique_ptr<Widget> upw2(new Widget); //不使用make函数
auto spw1(std::make_shared<Widget>()); //使用make函数
std::shared_ptr<Widget> spw2(new Widget); //不使用make函数

// 2 避免new出来对象和去构造shared_ptr之类的指针这两步之间发生异常导致资源泄漏(异常安全规避)
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak!

// 答案和编译器将源码转换为⽬标代码有关。在运⾏时,⼀个函数的参数必须先被计算,才能被调⽤,所 以在调⽤processWidget之前,必须执⾏以下操作,processWidget才开始执⾏:表达式'new Widget'必须计算,例如,⼀个Widget对象必须在堆上被创建 负责管理new出来指针的 std::shared_ptr<Widget> 构造函数必须被执⾏computePriority()必须运⾏
// 编译器不需要按照执⾏顺序⽣成代码。“new Widget"必须在 std::shared_ptr 的构造函数被调⽤前执 ⾏,因为new出来的结果作为构造函数的参数,但compute Priority可能在这之前,之后,或者之间执 ⾏。也就是说,编译器可能按照这个执⾏顺序⽣成代码:
// 1. 执行“`new Widget`”
// 2. 执行`computePriority`
// 3. 运行`std::shared_ptr`构造函数
// 如果按照这样⽣成代码,并且在运⾏是computePriority产⽣了异常,那么第⼀步动态分配的Widget就 会泄露。因为它永远都不会被第三步的 std::shared_ptr 所管理了。于是我们使用make_shared:
processWidget(std::make_shared<Widget>(), computePriority());
// 在运⾏时, std::make_shared 和computePriority会先被调⽤。如果是 std::make_shared ,在computePriority调⽤前,动态分配Widget的原始指针会安全的保存在作为返回值的 std::shared_ptr 中。如果compu tePriority⽣成⼀个异常,那么 std::shared_ptr 析构函数将确保管理的Widget被销 毁。如果⾸先调⽤computePriority并产⽣⼀个异常,那么 std::make_shared 将不会被调⽤,因此也 就不需要担⼼new Widget(会泄露)。

// 3 std::make_shared 的⼀个特性(与直接使⽤new相⽐)得到了效率提升。使⽤ std::make_shared 允许 编译器⽣成更小,更快的代码,并使⽤更简洁的数据结构。
std::shared_ptr<Widget> spw(new Widget);
// 显然,这段代码需要进⾏内存分配,但它实际上执⾏了两次。Item 19解释了每个 std::shared_ptr 指 向⼀个控制块,其中包含被指向对象的引⽤计数。这个控制块的内存在 std::shared_ptr 构造函数中分 配。因此,直接使⽤new需要为Widget分配⼀次内存,为控制块分配再分配⼀次内存。
// 如果使⽤ std::make_shared 代替: auto spw = std::make_shared_ptr<Widget>(); ⼀次分配⾜ 矣。这是因为 std::make_shared 分配⼀块内存,同时容纳了Widget对象和控制块。这种优化减少了程 序的静态⼤小,因为代码只包含⼀个内存分配调⽤,并且它提⾼了可执⾏代码的速度,因为内存只分配 ⼀次。对于 std::make_shared 的效率分析同样适⽤于 std::allocate_shared



// 缺点1 没有make函数允许指定定制的析构,但是new出来的指针则可以
auto widgetDeleter = [](Widget*){...};
std::unique_ptr<Widget, decltype(widgetDeleter)> upw(new Widget, widgetDeleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

// 缺点2 具体的语法细节 -- std::initializer_list在使用圆括号和大括号调用的构造函数不同
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);
// 两种调⽤都创建了10个元素,每个值为20.这意味着在make函数中,完美转发 使⽤圆括号,而不是⼤括号。坏消息是如果你想⽤⼤括号初始化指向的对象,你必须直接使⽤new。正如item31所说,⼤括号初始化⽆法完美转发。 但是,item30介绍了⼀个变通的⽅法:使⽤auto类型推导从⼤括号初始化创建std::initializer_list对象 (⻅Item 2),然后将auto创建的对象传递给make函数。
// 对于std::unique_ptr,只有这两种情景(定制删除和⼤括号初始化)使⽤make函数有点问题。但是shared_ptr还会有如下2个问题

// 缺点3(仅shared_ptr) 适⽤make函数去创建重载了operator new 和 operator delete类的对象是糟糕想法
// ⼀些类重载了operator new和operator delete。这些函数的存在意味着对这些类型的对象的全局内存分 配和释放是不合常规的。设计这种定制类往往只会精确的分配、释放对象的⼤小。例如,Widget类的operator new和operator delete只会处理sizeof(Widget)⼤小的内存块的分配和释放。这种常识不太适 ⽤于 std::shared_ptr 对定制化分配(通过std::allocate_shared)和释放(通过定制化deleters),因为std::allocate_shared需要的内存总⼤小不等于动态分配的对象⼤小,还需要再加上控制块⼤小。因此, 适⽤make函数去创建重载了operator new 和 operator delete类的对象是个典型的糟糕想法。
// 简言之:就是说shared_ptr调用make的时候会将对象的内存直接分配到控制块里(这也是shared_ptr的大小和速度优势),但是控制块里还有什么weak_ptr的cnt,也就是说当weak_ptr一直在,那么控制块不会释放,导致对象内存也不会释放,但是如果使用new,控制块和对象内存是分离的,当对象的shared_ptr的引用计数为0,但是weak_ptr不是0,那么会里马释放对象的内存
class ReallyBigType { … };

auto pBigObj = //通过std::make_shared
std::make_shared<ReallyBigType>(); //创建一个大对象

//创建std::shared_ptrs和std::weak_ptrs
//指向这个对象,使用它们

//最后一个std::shared_ptr在这销毁,
//但std::weak_ptrs还在

//在这个阶段,原来分配给大对象的内存还分配着

//最后一个std::weak_ptr在这里销毁;
//控制块和对象的内存被释放

// 直接只用`new`,一旦最后一个`std::shared_ptr`被销毁,`ReallyBigType`对象的内存就会被释放:
class ReallyBigType { … }; //和之前一样

std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);
//通过new创建大对象

//像之前一样,创建std::shared_ptrs和std::weak_ptrs
//指向这个对象,使用它们

//最后一个std::shared_ptr在这销毁,
//但std::weak_ptrs还在;
//对象的内存被释放

//在这阶段,只有控制块的内存仍然保持分配

//最后一个std::weak_ptr在这里销毁;
//控制块内存被释放



// 注意左右值的区别,主要是shared_ptr的拷贝对于引用计数有原子加的操作,带来了性能开销,使用std::move转为右值提高性能
// 异常不安全:回想⼀下:如果computePriority在“new Widget”之后,而在 std::shared_ptr 构造函数之前调⽤,并且 如果computePriority产⽣⼀个异常,那么动态分配的Widget将会泄漏
processWidget(
std::shared_ptr<Widget>(new Widget, cusDel), // arg is rvalue
computePriority()
);
// 异常安全:
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); // 正确,但是没优化,⻅下
// 因为 std::shared_ptr 假定了传递给它的构造函数的原始指针的所有权,即使构造函数产 ⽣了⼀个异常。此例中,如果spw的构造函数抛出异常(即⽆法为控制块动态分配内存),仍然能够保证cusDel会在new Widget产⽣的指针上调⽤。

// ⼀个小小的性能问题是,在异常不安全调⽤中,我们将⼀个右值传递给processWidget,但是在异常安全调⽤中,我们传递了左值;因为processWidget的 std::shared_ptr 参数是传值,传右值给构造函数只需要move,而传递左值需 要拷⻉。对 std::shared_ptr 而⾔,这种区别是有意义的,因为拷⻉ std::shared_ptr 需要对引⽤计 数原⼦加,move则不需要对引⽤计数有操作。为了使异常安全代码达到异常不安全代码的性能⽔平,我 们需要⽤std::move将spw转换为右值.
// 优化版本的异常安全调用
processWidget(std::move(spw), computePriority());

rem

  • 和直接使⽤new相⽐,make函数消除了代码重复,提⾼了异常安全性。
  • 对于 std::make_shared 和 std::allocate_shared ,⽣成的代码更小更快。
  • 不适合使⽤make函数的情况包括需要指定⾃定义删除器和希望⽤⼤括号初始化
  • 对于 std::shared_ptr s, make函数可能不被建议的其他情况包括
    • (1)有⾃定义内存管理的类和
    • (2)特别关注内存的系统,⾮常⼤的对象,以及 std::weak_ptr s⽐对应的 std::shared_ptr s活得 更久

item 22 当使⽤Pimpl惯⽤法,请在实现⽂件中定义特殊成员函数

会对 Pimpl (Pointer to implementation)惯⽤法很熟悉。 凭借 这样⼀种技巧,你可以将⼀个类数据成员替换成⼀个指向包含具体实现的类或结构体的指针, 并将放在主 类(primary class)的数据成员们移动到实现类去(implementation class), 而这些数据成员的访问将通过指针间接访问。 举个例⼦,假如有⼀个类 Widget 看起来如下:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class Widget() {                    //定义在头文件“widget.h”
public:
Widget();

private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; //Gadget是用户自定义的类型
};

// 因为类 Widget 的数据成员包含有类型 std::string , std::vector 和 Gadget , 定义有这些类型的头 ⽂件在类 Widget 编译的时候,必须被包含进来,这意味着类 Widget 的使⽤者必须要 #include <string>,<vector> 以及 gadget.h 。这些头⽂件将会增加类 Widget 使⽤者的编译时间,并且让这些 使⽤者依赖于这些头⽂件。

// 解决办法:在C++98中使⽤ Pimpl 惯⽤法,可以把 Widget 的数据成员替换成⼀个原始指针(raw pointer),指向⼀ 个已经被声明过却还未被定义的类,如下:
class Widget //仍然在“widget.h”中
{
public:
Widget();
~Widget(); //析构函数在后面会分析


private:
struct Impl; //声明一个 实现结构体
Impl *pImpl; //以及指向它的指针
};

// 将其改成c11风格
class Widget { //在“widget.h”中
public:
Widget();


private:
struct Impl;
std::unique_ptr<Impl> pImpl; //使用智能指针而不是原始指针
};
// 对应的实现文件:
#include "widget.h" //在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl { //跟之前一样
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
};

Widget::Widget() //根据条款21,通过std::make_unique
: pImpl(std::make_unique<Impl>()) //来创建std::unique_ptr
{}

// 以上的代码能编译,但是,最普通的 Widget ⽤法却会导致编译出错:
#include "widget.h" Wdiget w; //编译出错
// error提示: 会提到⼀些有关于把 sizeof 和 delete 应⽤到未完成类型 incomplete type 上的信息
// 在对象 w 被析构时,例如离开了作⽤域(scope),问题出现了。在这个时候,它的析构函数被调⽤。我们 在类的定义⾥使⽤了 std::unique_ptr ,所以我们没有声明⼀个析构函数,因为我们并没有任何代码需 要写在⾥⾯。根据编译器⾃动⽣成的特殊成员函数的规则(⻅ Item 17),编译器会⾃动为我们⽣成⼀个析 构函数。 在这个析构函数⾥,编译器会插⼊⼀些代码来调⽤类 Widget 的数据成员 Pimpl 的析构函数。 Pimpl 是⼀个 std::unique_ptr<Widget::Impl> ,也就是说,⼀个带有默认销毁器(default deleter)的 std::unique_ptr 。 默认销毁器(default deleter)是⼀个函数,它使⽤ delete 来销毁内置于 std::unique_ptr 的原始指针。然而,在使⽤ delete 之前,通常会使默认销毁器使⽤C++11的特性 static_assert 来确保原始指针指向的类型不是⼀个未完成类型。 当编译器为 Widget w 的析构⽣成代 码时,它会遇到 static_assert 检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象 w 销毁的地⽅出现,因为类 Widget 的析构函数,正如其他的编译器⽣成的特殊成员函数⼀样,是暗含 inline 属性的。 错误信息⾃⾝往往指向对象 w 被创建的那⾏,因为这⾏代码明确地构造了这个对象, 导致了后⾯潜在的析构。
// 为了解决这个问题,你只需要确保在编译器⽣成销毁 std::unique_ptr<Widget::Imple> 的代码之 前, Widget::Impl 已经是⼀个完成类型(complete type)。 当编译器"看到"它的定义的时候,该类型就 成为完成类型了。 但是 Widget::Impl 的定义在 wideget.cpp ⾥。成功编译的关键,就是,在 widget.cpp ⽂件内,让编译器在"看到" Widget 的析构函数实现之前(也即编译器⾃动插⼊销毁 std::unique_ptr 的数据成员的位置),先定义 Wdiget::Impl 。
class Widget { //跟之前一样,在“widget.h”中
public:
Widget();
~Widget(); //只有声明语句


private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};

#include "widget.h" //跟之前一样,在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl { //跟之前一样,定义Widget::Impl
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}

Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() //析构函数的定义(译者注:这里高亮)
{}

// 声明⼀个类 Widget 的析构函数会阻 ⽌编译器⽣成移动操作,所以如果你想要⽀持移动操作,你必须⾃⼰声明相关的函数。考虑到编译器⾃ 动⽣成的版本能够正常功能,你可能会被诱使着来这样实现:
class Widget { //仍然在“widget.h”中
public:
Widget();
~Widget();

Widget(Widget&& rhs) = default; //思路正确,
Widget& operator=(Widget&& rhs) = default; //但代码错误


private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};

// 这样的做法会导致同样的错误,和之前的声明⼀个不带析构函数的类的错误⼀样,并且是因为同样的原 因。 编译器⽣成的移动赋值操作符(move assignment operator),在重新赋值之前,需要先销毁指针 pImpl 指向的对象。然而在 Widget 的头⽂件⾥, pImpl 指针指向的是⼀个未完成类型。情况和移动构 造函数(move constructor)有所不同。 移动构造函数的问题是编译器⾃动⽣成的代码⾥,包含有抛出异 常的事件,在这个事件⾥会⽣成销毁 pImpl 的代码。然而,销毁 pImpl 需要 Impl 是⼀个完成类型。
class Widget { //仍然在“widget.h”中
public:
Widget();
~Widget();

Widget(Widget&& rhs); //只有声明
Widget& operator=(Widget&& rhs);


private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};

#include <string> //跟之前一样,仍然在“widget.cpp”中


struct Widget::Impl { … }; //跟之前一样

Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default; //跟之前一样

Widget::Widget(Widget&& rhs) = default; //这里定义
Widget& Widget::operator=(Widget&& rhs) = default;

rem

  • pImpl 惯⽤法通过减少在类实现和类使⽤者之间的编译依赖来减少编译时间。
  • 对于 std::unique_ptr 类型的 pImpl 指针,需要在头⽂件的类⾥声明特殊的成员函数,但是在实 现⽂件⾥⾯来实现他们。即使是编译器⾃动⽣成的代码可以⼯作,也要这么做。
  • 以上的建议只适⽤于 std::unique_ptr ,不适⽤于 std::shared_ptr 。

—– chapter 5 —– 右值引用,移动语句和完美转发

在本章的这些小节中,⾮常重要的⼀点是要牢记参数(parameter)永远是左值(lValue),即使它的类型是 ⼀个右值引⽤。⽐如,假设

1
void f(Widget&& w);

item 23 理解std::move和std::forward

为了了解std::movestd::forward,一种有用的方式是从它们不做什么这个角度来了解它们。std::move不移动(move)任何东西,std::forward也不转发(forward)任何东西。在运行时,它们不做任何事情。它们不产生任何可执行代码,一字节也没有。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 1 std::move的可能实现
template<typename T> //在std命名空间
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType = //别名声明,见条款9
typename remove_reference<T>::type&&;

return static_cast<ReturnType>(param);
} // cpp11版本,因为无法推断返回类型
template<typename T>
decltype(auto) move(T&& param) //C++14,仍然在std命名空间
{
using ReturnType = remove_referece_t<T>&&;
return static_cast<ReturnType>(param);
} // c14版本
// 有⼀些提议说它的名字叫 rvalue_cast 可 能会更好。虽然可能确实是这样,但是它的名字已经是 std::move ,所以记住 std::move 做什么和不 做什么很重要。它其实并不移动任何东西。

class Annotation {
public:
explicit Annotation(const std::string text)
value(std::move(text)) //“移动”text到value里;这段代码执行起来
{ … } //并不是看起来那样

private:
std::string value;
};
class string { //std::string事实上是
public: //std::basic_string<char>的类型别名

string(const string& rhs); //拷贝构造函数
string(string&& rhs); //移动构造函数

};
// 在类`Annotation`的构造函数的成员初始化列表中,`std::move(text)`的结果是一个`const std::string`的右值。这个右值不能被传递给`std::string`的移动构造函数,因为移动构造函数只接受一个指向**non-`const`**的`std::string`的右值引用。然而,该右值却可以被传递给`std::string`的拷贝构造函数,因为lvalue-reference-to-`const`允许被绑定到一个`const`右值上。因此,`std::string`在成员初始化的过程中调用了**拷贝**构造函数,即使`text`已经被转换成了右值。这样是为了确保维持`const`属性的正确性。从一个对象中移动出某个值通常代表着修改该对象,所以语言不允许`const`对象被传递给可以修改他们的函数(例如移动构造函数)。
// 从这个例子中,可以总结出两点。第一,不要在你希望能移动对象的时候,声明他们为`const`。对`const`对象的移动请求会悄无声息的被转化为拷贝操作。第二点,`std::move`不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。关于`std::move`,你能确保的唯一一件事就是将它应用到一个对象上,你能够得到一个右值。

// 关于 std::forward 的故事与 std::move 是相似的,但是与 std::move 总是⽆条件的将它的参数转换 为右值不同, std::forward 只有在满⾜⼀定条件的情况下才执⾏转换。std::forward是有条件的,下面是一个典型用法:
void process(const Widget& lvalArg); //处理左值
void process(Widget&& rvalArg); //处理右值

template<typename T> //用以转发param到process的模板
void logAndProcess(T&& param)
{
auto now = //获取现在时间
std::chrono::system_clock::now();

makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}
// call
Widget w;

logAndProcess(w); //用左值调用
logAndProcess(std::move(w)); //用右值调用
// 函数 process 分别对左值和右值 参数做了重载。当我们使⽤左值来调⽤ logAndProcess 时,⾃然我们期望该左值被当作左值转发给 process 函数,而当我们使⽤右值来调⽤ logAndProcess 函数时,我们期望 process 函数的右值重载 版本被调⽤。
// 由于所有的参数都是左值,所以如果上面不用std::forward,将总是调用左值重载版本的函数,这就是为什么 std::forward 是⼀个有条件的转换:它只把由右值初 始化的参数,转换为右值。
// 考虑到 std::move 和 std::forward 都可以归结于转换,他们唯⼀的区别就是 std::move 总是执⾏转 换,而 std::forward 偶尔为之。你可能会问是否我们可以免于使⽤ std::move 而在任何地⽅只使⽤ std::forward 。 从纯技术的⻆度,答案是yes: std::forward 是可以完全胜任, std::move 并⾮必 须。当然,其实两者中没有哪⼀个函数是真的必须的,因为我们可以到处直接写转换代码,但是我希望 我们能同意:这将相当的,嗯,让⼈恶⼼。

// 统计移动构造函数被调用的次数
class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s))
{ ++moveCtorCalls; }

private:
static std::size_t moveCtorCalls;
std::string s;
}; // std::move version
class Widget{
public:
Widget(Widget&& rhs) //不自然,不合理的实现
: s(std::forward<std::string>(rhs.s))
{ ++moveCtorCalls; }

}; // std::forward version
// 为什么使用move的version: 1,根绝了传递错误了理性的可能,forward中传入rhs.s如果是string&类型的话,会导致s被复制而不是移动构造,2,std::move只需要更少的参数
// 更重要的是, std::move 的使⽤代表着⽆条件向右值的转换,而使⽤ std::forward 只对绑定了右值的 引⽤进⾏到右值转换。这是两种完全不同的动作。前者是典型地为了移动操作,而后者只是传递(亦作 转发)⼀个对象到另外⼀个函数,保留它原有的左值属性或右值属性。

rem

  • std::move 执⾏到右值的⽆条件的转换,但就⾃⾝而⾔,它不移动任何东西。
  • std::forward 只有当它的参数被绑定到⼀个右值时,才将参数转换为右值。
  • std::move 和 std::forward 在运⾏期什么也不做。

item 24 区分通用引用和右值已你用

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// T&& 有两种不同的意思。第⼀种,当然是右值引⽤。这种引⽤表现得正如你所期待的那样: 它们 只绑定到右值上,并且它们主要的存在原因就是为了声明某个对象可以被移动。
// T&& 的第⼆层意思,是它既可以是⼀个右值引⽤,也可以是⼀个左值引⽤, 此外,它们还可以绑定 到常量(const)和⾮常量(non-const)的对象上,也可以绑定到 volatile 和 non-volatile 的对象上,甚 ⾄可以绑定到即 const ⼜ volatile 的对象上。它们可以绑定到⼏乎任何东西。这种空前灵活的引⽤值 得拥有⾃⼰的名字。我把它叫做通⽤引⽤(universal references)。
// 区分1: 有没有类型推导
// 没有type deduction
void f(Widget&& param); //右值引用
Widget&& var1 = Widget(); //右值引用

// 有type deduction
auto&& var2 = var1; //不是右值引用
template<typename T>
void f(T&& param); //不是右值引用
template<typename MyTemplateType> //param是通用引用
void someFunc(MyTemplateType&& param);

// 区分2:在通用引用的类型推导中,必须是标准的 T&& 的格式
// vector<T>&& param, const T&& param, param都不是通用的
template<typename T>
void f(std::vector<T>&& param); //右值引用
// 但 是参数 param 的类型声明并不是 T&& ,而是⼀个 std::vector<T>&& 。这排除了参数 param 是⼀个通⽤ 引⽤的可能性。 param 因此是⼀个右值引⽤——当你向函数 f 传递⼀个左值时,你的编译器将会开⼼地 帮你确认这⼀点:
std::vector<int> v;
f(v); //错误!不能将左值绑定到右值引用
// 一个const,也失去了通用引用的资格!
template <typename T>
void f(const T&& param); //param是一个右值引用

// 区分2的特例:由于在模板类内部无法保证类型推到发生,所有T&&也有可能不是通用引用
template<class T, class Allocator = allocator<T>> //来自C++标准
class vector
{
public:
void push_back(T&& x);

}
// 为何无法保证,因为这个vector在被实例化之前不可能存在,于是T必定是某种实例化好的类型,所以必然是一个右值引用,也就是:
std::vector<Widget> v; // 导致下面的特例化以后的类
class vector<Widget, allocator<Widget>> {
public:
void push_back(Widget&& x); //右值引用

};
// 然而,它的emplace_back却包含类型推到
template<class T, class Allocator = allocator<T>> //依旧来自C++标准
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);

};
// 类型参数(type parameter) Args 是独⽴于 vector 的类型参数之外的,所以 Args 会在每次 emplace_back 被调⽤的时候被推导(Okay, Args 实际上是⼀个参数包(parameter pack),而不是⼀个类 型参数

// 通用引用的实例:一个匿名函数记录所有函数的花费时间
auto timeFuncInvocation =
[](auto&& func, auto&&... params) //C++14
{
start timer;
std::forward<decltype(func)>(func)( //对params调用func
std::forward<delctype(params)>(params)...
);
stop timer and record elapsed time;
};
// 。 args 是0个或者多个 通⽤引⽤(也就是说,它是个通⽤引⽤参数包(a universal reference parameter pack)),它可以绑定 到任意数⽬、任意类型的对象上。

牢记整个本小节——通⽤引⽤的基础——是⼀个谎⾔,uhh,⼀个“抽象”。隐藏在其底下的真相被称为”引⽤ 折叠(reference collapsing)”,小节Item 28致⼒于讨论它。而且,通用引用,传入的如果是左右值,你将对应的获得该值的左右值引用

rem

  • 如果⼀个函数模板参数的类型为 T&& ,并且 T 需要被推导得知,或者如果⼀个对象被声明为 auto&& ,这个参数或者对象就是⼀个通⽤引⽤。
  • 如果类型声明的形式不是标准的 type&& ,或者如果类型推导没有发⽣,那么 type&& 代表⼀个右 值引⽤。
  • 通⽤引⽤,如果它被右值初始化,就会对应地成为右值引⽤;如果它被左值初始化,就会成为左值引⽤。

item 25 对右值引⽤使⽤std::move,对通⽤引⽤使⽤std::forward

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 可以将万能引用变成左值右值重载版本,但是这将导致可扩展性差的问题
class Widget {
public:
template<typename T>
void setName(T&& newName) //通用引用可以编译,
{ name = std::move(newName); } //但是代码太太太差了!

private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); //工厂函数

Widget w;
auto n = getWidgetName(); //n是局部变量
w.setName(n); //把n移动进w!
//现在n的值未知
// 那左右值重载版本:
class Widget {
public:
void setName(const std::string& newName) //用const左值设置
{ name = newName; }
void setName(std::string&& newName) //用右值设置
{ name = std::move(newName); }

};
// 带来可扩展性差的问题:Widget::setName 接受⼀个参数,可以是左值或者右值,因此需要两种重载实现, n 个参数的话, 就要实现2^n种重载。这还不是最坏的。有的函数---函数模板----接受⽆限制参数,每个参数都可以是 左值或者右值。此类函数的例⼦⽐如 std::make_unique 或者 std::make_shared 。查看他们的的重载 声明:
template<class T, class... Args> //来自C++11标准
shared_ptr<T> make_shared(Args&&... args);
template<class T, class... Args> //来自C++14标准
unique_ptr<T> make_unique(Args&&... args);
// 对于这种函数,对于左值和右值分别重载就不能考虑了:通⽤引⽤是仅有的实现⽅案。对这种函数,我 向你保证,肯定使⽤ std::forward 传递通⽤引⽤给其他函数。

// 返回值使用右值可能的收益: 将拷贝构造变为移动构造
Matrix //同之前一样
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return lhs; //拷贝lhs到返回值中
}
Matrix //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs); //移动lhs到返回值中
}
// 事实上,lhs作为左值,会被编译器拷⻉到返回值的内存空间。假定Matrix⽀持移动操作,并且⽐拷⻉操 作效率更⾼,使⽤ std::move 的代码效率更⾼。 如果Matrix不⽀持移动操作,将其转换为左值不会变差,因为右值可以直接被Matrix的拷⻉构造器使 ⽤。如果Matrix随后⽀持了移动操作, + 操作符的定义将在下⼀次编译时受益。就是这种情况,通过将 std::move 应⽤到返回语句中,不会损失什么,还可能获得收益,但是注意,这是在不考虑编译器的RVO(return value optimization的情况下),因为RVO会在如下两个条件下执行:
// 1. 局部变量与返回值的类型相同;2. 局部变量就是返回值
举个例子:
Widget makeWidget() //makeWidget的“拷贝”版本
{
Widget w;

return w; //“拷贝”w到返回值中
} // 因为满足RVO条件,所以编译器自动优化,省去了拷贝,让代码看起来和下面一样
Widget makeWidget() //makeWidget的移动版本
{
Widget w;

return std::move(w); //移动w到返回值中(不要这样做!)
}
// 返回的已经不是局部对象w,而是局部对象w的引⽤。返回局部对象的引⽤不满⾜RVO的第⼆个条件,所 以编译器必须移动w到函数返回值的位置。开发者试图帮助编译器优化反而限制了编译器的优化选项。意思就是不必要这样写,在有优化选项的情况下,这样的写法多此一举

rem

  • 在右值引⽤上使⽤ std::move ,在通⽤引⽤上使⽤ std::forward
  • 对按值返回的函数返回值,⽆论返回右值引⽤还是通⽤引⽤,执⾏相同的操作
  • 当局部变量就是返回值是,不要使⽤ std::move 或者 std::forward

item 26 避免在通⽤引⽤上重载

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
std::multiset<std::string> names;           //全局数据结构
void logAndAdd(const std::string& name)
{
auto now = //获取当前时间
std::chrono::system_clock::now();
log(now, "logAndAdd"); //志记信息
names.emplace(name); //把name加到全局数据结构中;
} //emplace的信息见条款42
std::string petName("Darla");
logAndAdd(petName); //传递左值std::string
logAndAdd(std::string("Persephone")); //传递右值std::string
logAndAdd("Patty Dog"); //传递字符串字面值
// 在第三个调⽤中,参数 name 绑定⼀个右值,但是这次是通过"Patty Dog"隐式创建的临时 std::string 变量。在第⼆个调⽤总, name 被拷⻉到 names ,但是这⾥,传递的是⼀个字符串字⾯量。直接将字符 串字⾯量传递给 emplace ,不会创建 std::string 的临时变量,而是直接在 std::multiset 中通过字 ⾯量构建 std::string 。在第三个调⽤中,我们会消耗 std::string 的拷⻉开销,但是连移动开销都 不想有,更别说拷⻉的,我们可以使用forward优化
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string petName("Darla"); //跟之前一样
logAndAdd(petName); //跟之前一样,拷贝右值到multiset
logAndAdd(std::string("Persephone")); //移动右值而不是拷贝它
logAndAdd("Patty Dog"); //在multiset直接创建std::string
//而不是拷贝一个临时std::string
// 此时我们还要求接受int类型,那么重载?
void logAndAdd(int idx) //新的重载
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
short nameIdx;
//给nameIdx一个值
logAndAdd(nameIdx); //错误!
// 根据正常的重载解决规则,精确匹配优 先于类型提升的匹配,所以被调⽤的是通⽤引⽤的重载,那么将short类型的通用引用完美转发到了multiset<string>的emplace里面,失败!所有的原因就是优先精确匹配,之后在类型提升匹配。
// 使⽤通⽤引⽤类型的函数在C++中是贪婪函数。他们机会可以精确匹配任何类型的参数(极少不适⽤的 类型在Item 30中介绍)。这也是组合重载和通⽤引⽤使⽤是糟糕主意的原因:通⽤引⽤的实现会匹配⽐ 开发者预期要多得多的参数类型。

// 如果拷⻉和移动构造被⽣成,Person类看起如下:
class Person {
public:
template<typename T> //完美转发的构造函数
explicit Person(T&& n)
: name(std::forward<T>(n)) {}

explicit Person(int idx); //int的构造函数

Person(const Person& rhs); //拷贝构造函数(编译器生成)
Person(Person&& rhs); //移动构造函数(编译器生成)

private:
std::string name;
};

Person p("Nancy");
auto cloneOfP(p); //从p创建新Person;这通不过编译!

// “为什么?”你可能会疑问,“为什么拷⻉构造会被完美转发构造替代?我们显然想拷⻉Person到另⼀个Person”。确实我们是这样想的,但是编译器严格遵循C++的规则,这⾥的相关规则就是控制对重载函数 调⽤的解析规则。 编译器的理由如下: cloneOfP 被 non-const 左值p初始化,这意味着可以实例化模板构造函数为采⽤ Person 的 non-const 左值。实例化之后, Person 类看起来是这样的:
class Person {
public:
explicit Person(Person& n) //由完美转发模板初始化
: name(std::forward<Person&>(n)) {}

explicit Person(int idx); //同之前一样

Person(const Person& rhs); //拷贝构造函数(编译器生成的)

};
auto cloneOfP(p);
// 那么这句,其中`p`被传递给拷贝构造函数或者完美转发构造函数。调用拷贝构造函数要求在`p`前加上`const`的约束来满足函数形参的类型,而调用完美转发构造不需要加这些东西。从模板产生的重载函数是更好的匹配,所以编译器按照规则:调用最佳匹配的函数。“拷贝”non-`const`左值类型的`Person`交由完美转发构造函数处理,而不是拷贝构造函数。然后我们会发现,尝试将n这个person类型初始化给了一个string类型的name,必然报错,倘若我们如下操作:
const Person cp("Nancy"); //现在对象是const的
auto cloneOfP(cp); //调用拷贝构造函数!
// 根据编译器精确匹配原则,我们会优先调用拷贝构造函数

rem

  • 对通⽤引⽤参数的函数进⾏重载,调⽤机会会⽐你期望的多得多
  • 完美转发构造函数是糟糕的实现,因为对于 non-const 左值不会调⽤拷⻉构造而是完美转发构造, 而且会劫持派⽣类对于基类的拷⻉和移动构造

item 27 熟悉通⽤引⽤重载的替代⽅法

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
// 0 先给出三种替换重载的方式:
// 0.1 Abandon overloading(放弃重载)例如两个重载的 logAndAdd 函数,可以分别改名为 logAndAddName 和 logAndAddNameIdx 。但是,这种⽅式不能⽤在第⼆个例⼦,Person构造函数中,因为构造函数的名字 本类名固定了。此外谁愿意放弃重载呢?
// 0.2 Pass by const T&(按照const T& 传参(导致通用应用推断失败)) ⼀种替代⽅案是退回到C++98,然后将通⽤引⽤替换为const的左值引⽤。事实上,这是Item 26中⾸先 考虑的⽅法。缺点是效率不⾼,会有拷⻉的开销。现在我们知道了通⽤引⽤和重载的组合会导致问题, 所以放弃⼀些效率来确保⾏为正确简单可能也是⼀种不错的折中。
// 0.3 Pass by value(按照值传递) 详细见item41


// 1 使用tag dispatch(标签分发)
std::multiset<std::string> names; //全局数据结构

template<typename T> //志记信息,将name添加到数据结构
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clokc::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
// 如果引⼊⼀个 int 类型的重载,就会重新陷⼊Item 26中描述的 ⿇烦。这个Item的⽬标是避免它。
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_instegral<typename std::remove_reference<T>::type>()
);
}
// 然后对type这个tag进行两种实现:通过tag来实现重载实现函数的“分发”
template<typename T> //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type) //译者注:高亮std::false_type
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string nameFromIdx(int idx); //与条款26一样,整型实参:查找名字并用它调用logAndAdd
void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
{
logAndAdd(nameFromIdx(idx));
}
// 那么使用tag dispatch最大的有点:tag dispatch的关键是存在单独⼀个函数(没有重载)给客⼾端API。

// 2 约束 使⽤通⽤引⽤的模板
// 实际上,真正的问题不是编译器生成的函数会绕过*tag diapatch*设计,而是不**总**会绕过去。你希望类的拷贝构造函数总是处理该类型的左值拷贝请求,但是如同item26中所述,提供具有通用引用的构造函数,会使通用引用构造函数在拷贝non-`const`左值时被调用(而不是拷贝构造函数)。那个条款还说明了当一个基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用那个完美转发构造函数,尽管正确的行为是调用基类的拷贝或者移动构造。
// std::enable_if 可以给你提供⼀种强制编译器执⾏⾏为的⽅法,即使特定模板不存在。这种模板也会 被禁⽌。默认情况下,所有模板是启⽤的,但是使⽤ std::enable_if 可以使得仅在条件满⾜时模板才 启⽤。在这个例⼦中,我们只在传递的参数类型不是 Person 使⽤ Person 的完美转发构造函数。如果传 递的参数是 Person ,我们要禁⽌完美转发构造函数(即让编译器忽略它),因此就是拷⻉或者移动构 造函数处理,这就是我们想要使⽤ Person 初始化另⼀个 Person 的初衷。
// 我很遗憾的表⽰你要⾃⾏查询语法含义,因为详细解释需要花费⼀定空 间和时间,而本书并没有⾜够的空间(在你⾃⾏学习过程中,请研究"SFINAE"[https://en.cppreference.com/w/cpp/language/sfinae]以及 std::enable_if , 因为“SFINAE”就是使 std::enable_if 起作⽤的技术)。
// condition: 想表⽰的条件是确认T不是 Person 类型
class Person {
public:
template<typename T,
typename = typename std::enable_if<condition>::type> //译者注:本行高亮,condition为某其他特定条件
explicit Person(T&& n);

};
// 这⾥我们想表⽰的条件是确认T不是 Person 类型,即模板构造函数应该在T不是 Person 类型的时候启 ⽤。因为type trait可以确定两个对象类型是否相同( std::is_same ),看起来我们需要的就 是 !std::is_same<Person, T>::value
Person p("Nancy");
auto cloneOfP(p); // initialize from lvalue
//T的类型在通⽤引⽤的构造函数中被推导为 Person&,我们的比较为: std::is_same<Person, Person&>::value 会是 false 。如果我们更精细考虑仅当T不是 Person 类型才启⽤模板构造函数,我们会意识到当我们查看T时,应该 忽略:
// - **是否是个引用**。对于决定是否通用引用构造函数启用的目的来说,`Person`,`Person&`,`Person&&`都是跟`Person`一样的。
// - **是不是`const`或者`volatile`**。如上所述,`const Person`,`volatile Person` ,`const volatile Person`也是跟`Person`一样的。
// 这意味着我们需要一种方法消除对于`T`的引用,`const`,`volatile`修饰。再次,标准库提供了这样功能的*type trait*,就是`std::decay`。`std::decay<T>::value`与`T`是相同的,只不过会移除引用和cv限定符(*cv-qualifiers*,即`const`或`volatile`标识符)的修饰。
// 那么最终代码变成:
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<
Person,
typename std::decay<T>::type
>::value
>::type
>
explicit Person(T&& n);

};
// 在上⾯的声明中,使 ⽤ Person 初始化⼀个 Person ----⽆论是左值还是右值, const 还是 volatile 都不会调⽤到通⽤引⽤ 构造函数。

// 假定从 Person 派⽣的类以常规⽅式实现拷⻉和移动操作:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) //拷贝构造函数,调用基类的
: Person(rhs) //完美转发构造函数!
{ … }

SpecialPerson(SpecialPerson&& rhs) //移动构造函数,调用基类的
: Person(std::move(rhs)) //完美转发构造函数!
{ … }


};
// 因为 SpecialPerson 和 Person 类型不同,所以完美转发构造函数是启⽤的,会实例化为精确匹 配的构造函数。⽣成的精确匹配的构造函数之于重载规则⽐基类的拷⻉或者移动构造函数更优,所以这 ⾥的代码,拷⻉或者移动 SpecialPerson 对象就会调⽤ Person 类的完美转发构造函数来执⾏基类的部 分。跟Item 26的困境⼀样。
// 但是实际上我们在拷贝时只想调用基类的拷贝,移动的时候只想调用基类的移动。
// 现在我们意识到不只是禁⽌ Person 类 型启⽤模板构造器,而是禁⽌ Person 以及任何派⽣⾃ Person 的类型启⽤模板构造器。讨厌的继承!
// 你应该不意外在这⾥看到标准库中也有type trait判断⼀个类型是否继承⾃另⼀个类型,就是 std::is_base_of 。
// 所以使⽤ std::is_base_of 代替 std::is_same 就可 以了:(相比于之前c++11的写法)
class Person { //C++14
public:
template<
typename T,
typename = std::enable_if_t< //这儿更少的代码
!std::is_base_of<Person,
std::decay_t<T> //还有这儿
>::value
> //还有这儿
>
explicit Person(T&& n);

};

// 我们已经知道如何使⽤ std::enable_if 来选择性禁⽌ Person 通⽤引⽤构造器来使得⼀些参数确保使 ⽤到拷⻉或者移动构造器,但是我们还是不知道将其应⽤于区分整型参数和⾮整型参数。毕竟,我们的 原始⽬标是解决构造函数模糊性问题。
// (1)加入一个`Person`构造函数重载来处理整型参数;
// (2)约束模板构造函数使其对于某些实参禁用。使用这些我们讨论过的技术组合起来,就能解决这个问题了:

class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) //对于std::strings和可转化为
: name(std::forward<T>(n)) //std::strings的实参的构造函数
{ … }

// 上面的限定保证了:
// 当T不是base或者base的派生类并且T不是整形的时候,才会使用通用引用的构造函数
explicit Person(int idx) //对于整型实参的构造函数
: name(nameFromIdx(idx))
{ … }

//拷贝、移动构造函数等

private:
std::string name;
};
// 因为使⽤了完美转发,所以具有最⼤效率,因为控制了使⽤通⽤引⽤的范围,可 以避免对于⼤多数参数能实例化精确匹配的滥⽤问题(指都去用通用引用的构造函数了而不是那些拷贝构造和移动构造)。

// 3 tarde-off:
// 通常,完美转发更有效率,因为它避免了仅处于符合参数类型而创建临时对象。在 Person 构造函数的 例⼦中,完美转发允许将 Nancy 这种字符串字⾯量转发到容器内部的 std::string 构造器,不使⽤完美 转发的技术则会创建⼀个临时对象来满⾜传⼊的参数类型。
// 但是完美转发也有缺点。·即使某些类型的参数可以传递给特定类型的参数的函数,也⽆法完美转发。Item 30中探索了这⽅⾯的例⼦。 第⼆个问题是当client传递⽆效参数时错误消息的可理解性。例如假如创建⼀个 Person 对象的client传 递了⼀个由 char16_t (⼀种C++11引⼊的类型表⽰16位字符)而不是 char ( std::string 包含 的):
Person p(u"Konrad Zuse"); // "Konrad Zuse" consists of characters of type const char16_t//
// 本Item中讨论的前三种⽅法(放弃重载,使用const T&,直接传值),编译器将看到可⽤的采⽤ int 或者 std::string 的构造函数,并且 它们或多或少会产⽣错误消息,表⽰没有可以从 const char16_t 转换为 int 或者 std::string 的⽅ 法。
// 但是,基于完美转发的⽅法, const char16_t 不受约束地绑定到构造函数的参数。从那⾥将转发到 Person 的 std::string 的构造函数,在这⾥,调⽤者传⼊的内容( const char16_t 数组)与所需内容 ( std::string 构造器可接受的类型)发⽣的不匹配会被发现。由此产⽣的错误消息会让⼈更容易理解, 在我使⽤的编译器上,会产⽣超过160⾏错误信息。(单单这个问题就可以阻挠很多开发者在性能接口上不用通用引用)
// 在 Person 这个例⼦中,我们知道转发函数的通⽤引⽤参数要⽀持 std::string 的初始化,所以我们可 以⽤ static_assert 来确认是不是⽀持。 std::is_constructible type trait执⾏编译时测试⼀个类 型的对象是否可以构造另⼀个不同类型的对象,所以代码可以这样:
class Person {
public:
template< //同之前一样
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n)
: name(std::forward<T>(n))
{
//断言可以用T对象创建std::string
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);

//通常的构造函数的工作写在这

}

//Person类的其他东西(同之前一样)
};
// 如果client代码尝试使⽤⽆法构造 std::string 的类型创建 Person ,会导致指定的错误消息。不幸的 是,在这个例⼦中, static_assert 在构造函数体中,但是作为成员初始化列表的部分在检查之前。所 以我使⽤的编译器,结果是由 static_assert 产⽣的清晰的错误消息在常规错误消息(最多160⾏以上 那个)后出现。
// 其实就是报的错误太多了,以至于都不知道是啥错

rem

  • 通⽤引⽤和重载的组合替代⽅案包括使⽤不同的函数名,通过const左值引⽤传参,按值传递参 数,使⽤tag dispatch
  • 通过 std::enable_if 约束模板,允许组合通⽤引⽤和重载使⽤, std::enable_if 可以控制编译 器哪种条件才使⽤通⽤引⽤的实例
  • 通⽤引⽤参数通常具有⾼效率的优势,但是可⽤性就值得斟酌

item 28 理解引⽤折叠

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 引用的引用是不允许的
int x;
auto& & rx = x; //error! can't declare reference to reference

// 对通用应用传递左值
template<typename T>
void func(T&& param); //同之前一样
func(w); //用左值调用func;T被推导为Widget&
// 我们会得到:
void func(Widget& && param);
// 引用的引用!但是编译器没有报错。因为通用引用`param`被传入一个左值,所以`param`的类型应该为左值引用,但是编译器如何把`T`推导的类型带入模板变成如下的结果,也就是最终的函数签名?
void func(Widget& param);
// 答案是引⽤折叠。是的,禁⽌你声明引⽤的引⽤,但是编译器会在特定的上下⽂中使⽤,包括模板实例 的例⼦。当编译器⽣成引⽤的引⽤时,引⽤折叠指导下⼀步发⽣什么。存在两种类型的引⽤(左值和右值),所以有四种可能的引⽤组合(左值的左值,左值的右值,右值的 右值,右值的左值)。如果⼀个上下⽂中允许引⽤的引⽤存在(⽐如,模板函数的实例化),引⽤根据 规则折叠为单个引⽤就能够解释清除
// 如果任⼀引⽤为左值引⽤,则结果为左值引⽤。否则(即,如果引⽤都是右值引⽤),结果为右值 引⽤


// 引⽤折叠是 std::forward ⼯作的⼀种关键机制。就像Item25中解释的⼀样, std::forward 应⽤在通 ⽤引⽤参数上,所以经常能看到这样使⽤:
template<typename T>
void f(T&& fParam)
{
//做些工作
someFunc(std::forward<T>(fParam)); //转发fParam到someFunc
}
// 因为fParam是通⽤引⽤,我们知道参数T的类型将在传⼊具体参数时被编码。 std::forward 的作⽤是 当传⼊参数为右值时,即T为⾮引⽤类型,才将fParam(左值)转化为⼀个右值。
template<typename T> //在std命名空间
T&& forward(typename
remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}
// 这不是标准库版本的实现(忽略了⼀些接口描述),但是为了理解 std::forward 的⾏为,这些差异⽆ 关紧要。


// 看一个传入非右值具体的例子:假设传入到`f`的实参是`Widget`的左值类型。`T`被推导为`Widget&`,然后调用`std::forward`将实例化为`std::forward<Widget&>`。`Widget&`带入到上面的`std::forward`的实现中:
Widget& && forward(typename
remove_reference<Widget&>::type& param)
{ return static_cast<Widget& &&>(param); }
// `std::remove_reference<Widget&>::type`这个*type trait*产生`Widget`也就是
Widget& && forward(Widget& param)
{ return static_cast<Widget& &&>(param); }
// 根据引用折叠规则,返回值和强制转换可以化简,最终版本的`std::forward`调用就是: 也就是
Widget& forward(Widget& param)
{ return static_cast<Widget&>(param); }
// 正如你所看到的,当左值被传⼊到函数模板f时, std::forward 转发和返回的都是左值引⽤。内部的转 换不做任何事,因为param的类型已经是 Widget& ,所以转换没有影响。左值传⼊会返回左值引⽤。通 过定义,左值引⽤就是左值,因此将左值传递给 std::forward 会返回左值,就像说的那样,完美转 发。

// 现在假设⼀下,传递给f的是⼀个 Widget 的右值。在这个例⼦中,T的类型推导就是Widget。内部的 std::forward 因此转发 std::forward<Widget> ,带⼊回 std::forward 实现中:
Widget&& forward(typename
remove_reference<Widget>::type& param)
{ return static_cast<Widget&&>(param); }
// 将 remove_reference 引⽤到⾮引⽤的类型上还是相同的类型,所以化简如下
Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }
// 这⾥没有引⽤的引⽤,所以不需要引⽤折叠,这就是最终版本。 从函数返回的右值引⽤被定义为右值,因此在这种情况下, std::forward 会将f的参数fParam(左 值)转换为右值。最终结果是,传递给f的右值参数将作为右值转发给someFunc,完美转发。
template<typename T> //C++14;仍然在std命名空间
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}


// 具体看引用折叠的4个发生情况:
// case1
Widget widgetFactory(); //返回右值的函数
Widget w; //一个变量(左值)
func(w); //用左值调用func;T被推导为Widget&
func(widgetFactory()); //用又值调用func;T被推导为Widget
auto&& w1 = w;
// case2: 在auto的写法中,规则是类似的: auto&& w1 = w; 初始化 w1 为⼀个左值,因此为auto推导出类型 Widget& 。带回去就是 Widget& && w1 = w ,应⽤引⽤折叠规则,就是 Widget& w1 = w ,结果就是 w1 是⼀个左值引⽤。
auto&& w2 = widgetFactory();
// 另⼀⽅⾯, auto&& w2 = widgetFactory(); 使⽤右值初始化 w2 ,⾮引⽤带回 Widget&& w2 = widgetFactory() 。没有引⽤的引⽤,这就是最终结果。

// 现在我们真正理解了Item24中引⼊的通⽤引⽤。通⽤引⽤不是⼀种新的引⽤,它实际上是满⾜两个条件 下的右值引⽤:
// 1. 通过类型推导将左值和右值区分。T类型的左值被推导为&类型,T类型的右值被推导为T
// 2. 引⽤折叠的发⽣
// 通⽤引⽤的概念是有⽤的,因为它使你不必⼀定意识到引⽤折叠的存在,从直觉上判断左值和右值的推 导即可。

// 上面我们讨论了模板实例化和auto推断带来的2种引用折叠的发生情况:
// case3: 是使⽤typedef和别名声明
template<typename T>
class Widget {
public:
typedef T&& RvalueRefToT;

};
// 假设我们使⽤左值引⽤实例化Widget:Widget<int&> w;
typedef int& && RvalueRefToT;
// 引⽤折叠就会发挥作⽤:
typedef int& RvalueRefToT;

// case4: 最后,也是第四种情况是,decltype使⽤的情况,如果在分析decltype期间,出现了引⽤的引⽤,引⽤ 折叠规则就会起作⽤(关于decltype,参⻅Item3)

rem

  • 引⽤折叠发⽣在四种情况:模板实例化;auto类型推导;typedef的创建和别名声明;decltype
  • 当编译器⽣成了引⽤的引⽤时,结果通过引⽤折叠就是单个引⽤。有左值引⽤就是左值引⽤,否则 就是右值引⽤
  • 通⽤引⽤就是通过类型推导区分左值还是右值,并且引⽤折叠出现的右值引⽤

item 29 理智看待std::move Assume that move operations are not present, not cheap, and not used.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 考虑⼀下 std::array ,这是C++11中的新容器。 std::array 本质上是具有STL接口的内置数组。这与 其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本⾝只保存了只想堆内存数据 的指针(真正实现当然更复杂⼀些,但是基本逻辑就是这样)。这种实现使得在常数时间移动整个容器 成为可能的,只需要拷⻉容器中保存的指针到⽬标容器,然后将原容器的指针置为空指针就可以了。
std::vector<Widget> vm1;
auto vm2 = std::move(vm1); //把vw1移动到vw2。以常数时间运行。只有vw1和vw2中的指针被改变
// std::array 没有这种指针实现,数据就保存在 std::array 容器中
std::array<Widget, 10000> aw1;
auto aw2 = std::move(aw1); // move aw1 into aw2. Runs in linear time. All elements in aw1 are moved into aw2.
// 注意 aw1 中的元素被移动到了 aw2 中,这⾥假定 Widget 类的移动操作⽐复制操作快。但是使⽤ std::array 的移动操作还是复制操作都将花费线性时间的开销,因为每个容器中的元素终归需要拷⻉ ⼀次,这与“移动⼀个容器就像操作⼏个指针⼀样⽅便”的含义想去甚远。

// 另⼀⽅⾯, std::string 提供了常数时间的移动操作和线性时间的复制操作。这听起来移动⽐复制快多 了,但是可能不⼀定。许多字符串的实现采⽤了small string optimization(SSO)。"small"字符串(⽐如⻓ 度小于15个字符的)存储在了 std::string 的缓冲区中,并没有存储在堆内存,移动这种存储的字符串 并不必复制操作更快。SSO的动机是⼤量证据表明,短字符串是⼤量应⽤使⽤的习惯。使⽤内存缓冲区存储而不分配堆内存空 间,是为了更好的效率。然而这种内存管理的效率导致移动的效率并不必复制操作⾼。

// 因此,存在⼏种情况,C++11的移动语义并⽆优势:
// 1. No move operations:类没有提供移动操作,所以移动的写法也会变成复制操作
// 2. Move not faster:类提供的移动操作并不必复制效率更⾼
// 3. Move not usable:进⾏移动的上下⽂要求移动操作不会抛出异常,但是该操作没有被声明为 noexcept 值得⼀提的是,还有另⼀个场景,会使得移动并没有那么有效率:
// 4. Source object is lvalue:除了极少数的情况外(例如 Item25),只有右值可以作为移动操作的 来源
// 但是该Item的标题是假定不存在移动操作,或者开销不小,不使⽤移动操作。存在典型的场景,就是编 写模板代码,因为你不清楚你处理的具体类型是什么。在这种情况下,你必须像出现移动语义之前那 样,保守地考虑复制操作。不稳定的代码也是如此,类的特性经常被修改导致可能移动操作会有问题。


// 但是,通常,你了解你代码⾥使⽤的类,并且知道是否⽀持快速移动操作。这种情况,你⽆需这个Item的假设,只需要查找所⽤类的移动操作详细信息,并且调⽤移动操作的上下⽂中,可以安全的使⽤快速 移动操作替换复制操作。

rem

  • Assume that move operations are not present, not cheap, and not used.
  • 完全了解的代码可以忽略本Item

item 30 熟悉完美转发的失败case

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 完美转发意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const还 是volatile。结合到我们会处理引⽤参数,这意味着我们将使⽤通⽤引⽤(参⻅Item24),因为通⽤引⽤ 参数被传⼊参数时才确定是左值还是右值。

template<typename... Ts>
void fwd(Ts&&... params) //接受任何实参
{
f(std::forward<Ts>(params)...); //转发给f
}
f( expression ); //如果这个做某件事,
fwd( expression ); //但是这个做另外的某件事,fwd完美转发expression给f会失败


// 以下是几种具体的失败案例:
// case1: 大括号初始化器
void f(const std::vector<int>& v);
f({1,2,3}); // fine "{1,2,3}" implicitly converted to std::vector<int>
fwd({1,2,3}); // error! doesn't compile
// 在对f的直接调⽤(例如f({1,2,3})),编译器看到传⼊的参数是声明中的类 型。如果类型不匹配,就会执⾏隐式转换操作使得调⽤成功。在上⾯的例⼦中,从 {1,2,3} ⽣成了临时 变量 std::vector<int> 对象,因此f的参数会绑定到 std::vector<int> 对象上。
// 当通过调⽤函数模板fwd调⽤f时,编译器不再⽐较传⼊给fwd的参数和f的声明中参数的类型。代替的 是,推导传⼊给fwd的参数类型,然后⽐较推导后的参数类型和f的声明类型。当下⾯情况任何⼀个发⽣ 时,完美转发就会失败:
// 1. 编译器不能推导出⼀个或者多个fwd的参数类型,编译器就会报错
// 2. 编译器将⼀个或者多个fwd的参数类型推导错误。在这⾥,“错误”可能意味着fwd将⽆法使⽤推导出 的类型进⾏编译,但是也可能意味着调⽤者f使⽤fwd的推导类型对⽐直接传⼊参数类型表现出不一致的⾏为。这种不同⾏为的原因可能是因为f的函数重载定义,并且由于是“不正确的”类型推导,在fwd内部调⽤f和直接调⽤f将重载不同的函数。

// 在上⾯的 f({1,2,3}) 例⼦中,问题在于,如标准所⾔,将括号初始化器传递给未声明为 std::initializer_list 的函数模板参数,该标准规定为“⾮推导上下⽂”。简单来讲,这意味着编译器 在对fwd的调⽤中推导表达式 {1,2,3} 的类型,因为fwd的参数没有声明为 std::initializer_list 。 对于fwd参数的推导类型被阻⽌,编译器只能拒绝该调⽤。
// 有趣的是,Item2 说明了使⽤braced initializer的auto的变量初始化的类型推导是成功的。这种变量被 视为 std::initializer_list 对象,在转发函数应推导为 std::initializer_list 类型的情况,这 提供了⼀种简单的解决⽅法----使⽤auto声明⼀个局部变量,然后将局部变量转发:
auto il = {1,2,3}; // il's type deduced to be std::initializer_list<int>
fwd(il); // fine, perfect-forwards il to f


// case2: 0或者NULL作为空指针
// Item8说明当你试图传递0或者NULL作为空指针给模板时,类型推导会出错,推导为⼀个整数类型而不 是指针类型。结果就是不管是0还是NULL都不能被完美转发为空指针。解决⽅法⾮常简单,使⽤nullptr就可以了,具体的细节,参考Item 8.

// case3: 仅声明的整数静态const数据成员
// 通常,⽆需在类中定义整数静态const数据成员;声明就可以了。 That’s because compilers perform const propaga‐tion 对那些整数静态成员执行了“常量 传播”。
class Widget {
public:
static const std::size_t MinVals = 28; //MinVal的声明

};
//没有MinVals定义

std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); //使用MinVals
// 这⾥,我们使⽤ Widget::MinVals (或者简单点MinVals)来确定 widgetData 的初始容量,即使 MinVals 缺少定义。编译器通过将值28放⼊所有位置来补充缺少的定义。没有为 MinVals 的值留存储 空间是没有问题的。如果要使⽤ MinVals 的地址(例如,有⼈创建了 MinVals 的指针),则 MinVals 需要存储(因为指针总要有⼀个地址),尽管上⾯的代码仍然可以编译,但是链接时就会报错,直到为 MinVals 提供定义。
// 按照这个思路,想象下f(转发参数给fwd的函数)这样声明:
void f(std::size_t val);
f(Widget::MinVals); // fine, treated as "28"
fwd(Widget::MinVals); // error! shouldn't link
// 代码可以编译,但是不能链接。就像使⽤ MinVals 地址表现⼀样,确实,底层的问题是⼀样的。尽管代码中没有使⽤ MinVals 的地址,但是fwd的参数是通⽤引⽤,而引⽤,在编译器⽣成的代码中, 通常被视作指针。在程序的⼆进制底层代码中指针和引⽤是⼀样的。在这个⽔平下,引⽤只是可以⾃动 取消引⽤的指针。在这种情况下,通过引⽤传递 MinVals 实际上与通过指针传递 MinVals 是⼀样的,因 此,必须有内存使得指针可以指向。通过引⽤传递整型static const数据成员,必须定义它们,这个要求 可能会造成完美转发失败,即使等效不使⽤完美转发的代码成功。
// pointer vs reference: reference为变量的别名,然后不能改变它所指向的内容,理解成一个const ptr即可
// 确实,根据标准,通过引⽤传递 MinVals 要求有定义。但不是所有的实现都强制要求这⼀点。所以,取 决于你的编译器和链接器,为了具有可移植性,只要给整型static const提供⼀个定义,⽐如这样:
const std::size_t Widget::MinVals; // in Widget's .cpp file
// 注意定义中不要重复初始化(这个例⼦中就是赋值28)。不要忽略这个细节,否则,编译器就会报错, 提醒你只初始化⼀次。

// case4: 重载的函数名称和模板名称
void f(int (*pf)(int)); // pf = "process function"
void f(int pf(int)); // declares same f as above
int processVal(int value); int processVal(int value, int priority);
f(processVal); // fine
// 但是有⼀点要注意,f要求⼀个函数指针,但是 processVal 不是⼀个函数指针或者⼀个函数,它是两个 同名的函数。但是,编译器可以知道它需要哪个:通过参数类型和数量来匹配。因此选择了⼀个int参数 的 processVal 地址传递给f,⼯作的基本机制是让编译器帮选择f的声明选择⼀个需要的 processVal 。但是,fwd是⼀个函数模板, 没有需要的类型信息,使得编译器不可能帮助⾃动匹配⼀个合适的函数:
fwd(processVal); // error! which processVal?
// processVal 没有类型信息,就不能类型推导,完美转发失败。
// 同样的问题会发⽣在如果我们试图使⽤函数模板代替重载的函数名。⼀个函数模板是未实例化的函数, 表⽰⼀个函数族:
template<typename T> T workOnVal(T param) { ... } // template for processing values
fwd(workOnVal); // error! which workOnVal instantiation ?
// 那么正确的用例有:
using ProcessFuncType = //写个类型定义;见条款9
int (*)(int);
ProcessFuncType processValPtr = processVal; //指定所需的processVal签名
fwd(processValPtr); //可以
fwd(static_cast<ProcessFuncType>(workOnVal)); //也可以


// case5: 位域
// 完美转发最后⼀种失败的情况是函数参数使⽤位域这种类型。为了更直观的解释,IPv4的头部可以如下 定义:
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
};
void f(std::size_t sz); //要调用的函数

IPv4Header h;

f(h.totalLength); //可以
fwd(h.totalLength); //错误!
// 问题在于fwd的参数是引⽤,而h.totalLength是⾮常量位域。听起来并不是那么糟糕,但是C++标准⾮ 常清楚地谴责了这种组合:⾮常量引⽤不应该绑定到位域。禁⽌的理由很充分。位域可能包含了机器字 节的任意部分(⽐如32位int的3-5位),但是⽆法直接定位。我之前提到了在硬件层⾯引⽤和指针时⼀ 样的,所以没有办法创建⼀个指向任意bit的指针(C++规定你可以指向的最小单位是char),所以就没 有办法绑定引⽤到任意bit上。
// ⼀旦意识到接收位域作为参数的函数都将接收位域的副本,就可以轻松解决位域不能完美转发的问题。 毕竟,没有函数可以绑定引⽤到位域,也没有函数可以接受指向位域的指针(不存在这种指针)。这种 位域类型的参数只能按值传递,或者有趣的事,常量引⽤也可以。在按值传递时,被调⽤的函数接受了 ⼀个位域的副本,而且事实表明,位域的常量引⽤也是将其“复制”到普通对象再传递。传递位域给完美转发的关键就是利⽤接收参数函数接受的是⼀个副本的事实。你可以⾃⼰创建副本然后 利⽤副本调⽤完美转发。在IPv4Header的例⼦中,可以如下写法:
// copy bitfield value; see Item6 for info on init. form
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // forward the copy

rem

  • 完美转发会失败当模板类型推导失败或者推导类型错误
  • 导致完美转发失败的类型有
    • braced initializers,
    • 作为空指针的0或者NULL,
    • 只声明的整型static const数据成员,
    • 模板和重载的函数名
    • 位域

—— chapter 6 —— lambda 表达式

  • 闭包enclosure)是lambda创建的运行时对象。依赖捕获模式,闭包持有被捕获数据的副本或者引用。在上面的std::find_if调用中,闭包是作为第三个实参在运行时传递给std::find_if的对象。

  • 闭包类closure class)是从中实例化闭包的类。每个lambda都会使编译器生成唯一的闭包类。lambda中的语句成为其闭包类的成员函数中的可执行指令。

lambda通常被用来创建闭包,该闭包仅用作函数的实参。上面对std::find_if的调用就是这种情况。然而,闭包通常可以拷贝,所以可能有多个闭包对应于一个lambda。比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
{
int x; //x是局部对象


auto c1 = //c1是lambda产生的闭包的副本
[x](int y) { return x * y > 55; };

auto c2 = c1; //c2是c1的拷贝

auto c3 = c2; //c3是c2的拷贝

}

c1c2c3都是lambda产生的闭包的副本。

非正式的讲,模糊lambda,闭包和闭包类之间的界限是可以接受的。但是,在随后的Item中,区分什么存在于编译期(lambdas 和闭包类),什么存在于运行时(闭包)以及它们之间的相互关系是重要的。

item 31 避免使⽤默认捕获模式

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// C++11中有两种默认的捕获模式:按引⽤捕获和按值捕获。
// 1 按引⽤捕获的坏处
// 会导致闭包中包含了对局部变量或者某个形参(位于定义lambda的作⽤域)的引⽤,如果该lambda创建的闭包⽣命周期超过了局部变量或者参数的⽣命周期,那么闭包中的引⽤将会变成悬空引 ⽤。举个例⼦,假如我们有⼀个元素是过滤函数的容器,该函数接受⼀个int作为参数,并返回⼀个布尔 值,该布尔值的结果表⽰传⼊的值是否满⾜过滤条件。
using FilterContainer = //“using”参见条款9,
std::vector<std::function<bool(int)>>; //std::function参见条款2

FilterContainer filters; //过滤函数
filters.emplace_back( //emplace_back的信息见条款42
[](int value) { return value % 5 == 0; }
);
void addDivisorFilter()
{
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();

auto divisor = computeDivisor(calc1, calc2);

filters.emplace_back( //危险!对divisor的引用
[&](int value) { return value % divisor == 0; } //将会悬空!
);
}
// 这个代码实现是⼀个定时炸弹。lambda对局部变量divisor进⾏了引⽤,但该变量的⽣命周期会在addDivisorFilter返回时结束,刚好就是在语句filters.emplace_back返回之后,因此该函数的本质就是 容器添加完,该函数就死亡了。使⽤这个filter会导致未定义⾏为,这是由它被创建那⼀刻起就决定了 的。显示的捕获参数也会有这个问题,但是更容易让人意识到:
filters.emplace_back(
[&divisor](int value) // 危险!对divisor的引用将会悬空!
{ return value % divisor == 0; }
);
// 从⻓期来看,使⽤显式的局部变量和参数引⽤捕获⽅式,是更加符合软件⼯程规范的做法,接下来看一个具体例子:

// 2 按默认按值值捕获的坏处
// 2.1 缺点1: 悬空指针
filters.emplace_back( //现在divisor不会悬空了
[=](int value) { return value % divisor == 0; }
);
// 在通常情况下,按值捕获并不能完全解决悬空引用的问题。这里的问题是如果你按值捕获的是一个指针,你将该指针拷贝到*lambda*对应的闭包里,但这样并不能避免*lambda*外`delete`这个指针的行为,从而导致你的副本指针变成悬空指针。
class Widget {
public:
//构造函数等
void addFilter() const; //向filters添加条目
private:
int divisor; //在Widget的过滤器使用
};
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}
// 这个做法看起来是安全的代码。*lambda*依赖于`divisor`,但默认的按值捕获确保`divisor`被拷贝进了*lambda*对应的所有闭包中,对吗?
// 错误,完全错误。
// 捕获只能应用于*lambda*被创建时所在作用域里的non-`static`局部变量(包括形参)。在`Widget::addFilter`的视线里,`divisor`并不是一个局部变量,而是`Widget`类的一个成员变量。它不能被捕获。而如果默认捕获模式被删除,代码就不能编译了:
void Widget::addFilter() const
{
filters.emplace_back( //错误!
[](int value) { return value % divisor == 0; } //divisor不可用
);
}
void Widget::addFilter() const
{
filters.emplace_back(
[divisor](int value) //错误!没有名为divisor局部变量可捕获
{ return value % divisor == 0; }
);
}
// 所以如果默认按值捕获不能捕获`divisor`,而不用默认按值捕获代码就不能编译,这是怎么一回事呢?
// 解释就是这里隐式使用了一个原始指针:`this`。每一个non-`static`成员函数都有一个`this`指针,每次你使用一个类内的数据成员时都会使用到这个指针。例如,在任何`Widget`成员函数中,编译器会在内部将`divisor`替换成`this->divisor`。在默认按值捕获的`Widget::addFilter`版本中,
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}
void Widget::addFilter() const
{
auto currentObjectPtr = this;

filters.emplace_back(
[currentObjectPtr](int value)
{ return value % currentObjectPtr->divisor == 0; }
);
}
// 明白了这个就相当于明白了*lambda*闭包的生命周期与`Widget`对象的关系,闭包内含有`Widget`的`this`指针的拷贝。特别是考虑以下的代码
using FilterContainer = //跟之前一样
std::vector<std::function<bool(int)>>;
FilterContainer filters; //跟之前一样
void doSomeWork()
{
auto pw = //创建Widget;std::make_unique
std::make_unique<Widget>(); //见条款21
pw->addFilter(); //添加使用Widget::divisor的过滤器

} //销毁Widget;filters现在持有悬空指针!

// 这个特定的问题可以通过做⼀个局部拷⻉去解决:
void Widget::addFilter() const
{
auto divisorCopy = divisor; //拷贝数据成员

filters.emplace_back(
[divisorCopy](int value) //捕获副本
{ return value % divisorCopy == 0; } //使用副本
);
}
// 事实上如果采⽤这种⽅法,默认的按值捕获也是可⾏的。
void Widget::addFilter() const
{
auto divisorCopy = divisor; //拷贝数据成员

filters.emplace_back(
[=](int value) //捕获副本
{ return value % divisorCopy == 0; } //使用副本
);
}
// 但为什么要冒险呢?当一开始你认为你捕获的是`divisor`的时候,默认捕获模式就是造成可能意外地捕获`this`的元凶。
// C++14中,⼀个更好的捕获成员变量的⽅式时使⽤通⽤的lambda捕获:
void Widget::addFilter() const
{
filters.emplace_back( //C++14:
[divisor = divisor](int value) //拷贝divisor到闭包
{ return value % divisor == 0; } //使用这个副本
);
}
// 这种通⽤的lambda捕获并没有默认的捕获模式,因此在C++14中,避免使⽤默认捕获模式的建议仍然时 成⽴的。

// 2.2 默认的按值捕获还有另外的⼀个缺点, 它们预⽰了相关的闭包是独⽴的并且不受外部数据变化的影 响
// ⼀般来说,这是不对的。lambda并不会独⽴于局部变量和参数,但也没有不受静态存储⽣命周期的 影响。⼀个定义在全局空间或者指定命名空间的全局变量,或者是⼀个声明为static的类内或⽂件内的成 员。这些对象也能在lambda⾥使⽤,但它们不能被捕获。但按值引⽤可能会因此误导你,让你以为捕获 了这些变量。参考下⾯版本的addDivisorFilter()函数:
void addDivisorFilter()
{
static auto calc1 = computeSomeValue1(); //现在是static
static auto calc2 = computeSomeValue2(); //现在是static
static auto divisor = //现在是static
computeDivisor(calc1, calc2);

filters.emplace_back(
[=](int value) //什么也没捕获到!
{ return value % divisor == 0; } //引用上面的static
);

++divisor; //调整divisor
}
// 随意地看了这份代码的读者可能看到"[=]",就会认为“好的,lambda拷⻉了所有使⽤的对象,因此这是 独⽴的”。但上⾯的例⼦就表现了不独⽴闭包的⼀种情况。它没有使⽤任何的⾮static局部变量和形参, 所以它没有捕获任何东西。然而lambda的代码引⽤了静态变量divisor,任何lambda被添加到filters之 后,divisor都会递增。通过这个函数,会把许多lambda都添加到filiters⾥,但每⼀个lambda的⾏为都 是新的(分别对应新的divisor值)。这个lambda是通过引⽤捕获divisor,这和默认的按值捕获表⽰的 含义有着直接的⽭盾。如果你⼀开始就避免使⽤默认的按值捕获模式,你就能解除代码的⻛险。

rem

  • 默认的按引⽤捕获可能会导致悬空引⽤;
  • 默认的按值引⽤对于悬空指针很敏感(尤其是this指针),并且它会误导⼈产⽣lambda是独⽴的想 法;

item 32 使⽤初始化捕(⼴义lambda捕获)获来移动对象到闭包中

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// 使⽤初始化捕获可以让你指定: 
// 1. 从lambda⽣成的闭包类中的数据成员名称;
// 2. 初始化该成员的表达式;
class Widget { //一些有用的类型
public:

bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private:

};

auto pw = std::make_unique<Widget>(); //创建Widget;使用std::make_unique
//的有关信息参见条款21
//设置*pw
auto func = [pw = std::move(pw)] //使用std::move(pw)初始化闭包数据成员
{ return pw->isValidated()
&& pw->isArchived(); };
// 初始化捕获的使⽤,"="的左侧是指定的闭包类中数据成员的名称,右侧则是初始化表 达式。有趣的是,"="左侧的作⽤范围不同于右侧的作⽤范围。在上⾯的⽰例中,'='左侧的名称 pw 表⽰ 闭包类中的数据成员,而右侧的名称 pw 表⽰在lambda上⽅声明的对象,即由调⽤初始化的变量到调⽤ std::make_unique 。因此, pw = std :: move(pw) 的意思是“在闭包中创建⼀个数据成员pw,并通 过将 std::move 应⽤于局部变量pw的⽅法来初始化该数据成员。
// 这清楚地表明了,这个C ++ 14的捕获概念是从C ++11发展出来的的,在C ++11中,⽆法捕获表达式的 结果。 因此,初始化捕获的另⼀个名称是⼴义lambda捕获。
auto func = [pw = std::make_unique<Widget>()] //使用调用make_unique得到的结果
{ return pw->isValidated() //初始化闭包数据成员
&& pw->isArchived(); };

// 但是,如果您使⽤的⼀个或多个编译器不⽀持C ++ 14的初始捕获怎么办? 如何使⽤不⽀持移动捕获的 语⾔完成移动捕获?
class IsValAndArch { //“is validated and archived”
public:
using DataType = std::unique_ptr<Widget>;

explicit IsValAndArch(DataType&& ptr) //条款25解释了std::move的使用
: pw(std::move(ptr)) {}

bool operator()() const
{ return pw->isValidated() && pw->isArchived(); }

private:
DataType pw;
};

auto func = IsValAndArch(std::make_unique<Widget>());
// 这个代码量⽐lambda表达式要多,但这并不难改变这样⼀个事实,即如果你希望使⽤⼀个C++11的类来 ⽀持其数据成员的移动初始化,那么你唯⼀要做的就是在键盘上多花点时间。

// 如果你坚持要使⽤lambda(并且考虑到它们的便利性,你可能会这样做),可以在C++11中这样使⽤:
// 1. 将要捕获的对象移动到由 std::bind ;
// 2. 将被捕获的对象赋予⼀个引⽤给lambda;

// cpp14:
std::vector<double> data; //要移动进闭包的对象
//填充data
auto func = [data = std::move(data)] //C++14初始化捕获
{ /*使用data*/ };
// C++11的等效代码如下,其 中我强调了相同的关键事项:
auto func =
std::bind( //C++11模拟初始化捕获
[](const std::vector<double>& data) //译者注:本行高亮
{ /*使用data*/ },
std::move(data) //译者注:本行高亮
);
// 如lambda表达式⼀样, std::bind ⽣产了函数对象。我将它称呼为由std::bind所绑定对象返回的函数 对象。 std::bind 的第⼀个参数是可调⽤对象,后续参数表⽰要传递给该对象的值。这种移动构造是模仿移动捕获 的关键,因为将右值移动到绑定对象是我们解决⽆法将右值移动到C++11闭包中的⽅法。

// 默认情况下,从lambda⽣成的闭包类中的 operator() 成员函数为 const 的。这具有在lambda主体内 呈现闭包中的所有数据成员为 const 的效果。但是,绑定对象内部的移动构造数据副本不⼀定是 const 的,因此,为了防⽌在lambda内修改该数据副本,lambda的参数应声明为 const 引⽤。 如果将 lambda 声明为可变的,则不会在其闭包类中将 operator() 声明为const,并且在lambda的参数声明 中省略 const 也是合适的:
auto func =
std::bind( //C++11对mutable lambda
[](std::vector<double>& data) mutable //初始化捕获的模拟
{ /*使用data*/ },
std::move(data)
);
// 如果这是您第⼀次接触 std::bind ,则可能需要先阅读您最喜欢的C ++11参考资料,然后再进⾏讨论所 有详细信息。 即使是这样,这些基本要点也应该清楚:
// 1. ⽆法将移动构造⼀个对象到C ++11闭包,但是可以将对象移动构造为C++11的绑定对象。
// 2. 在C++11中模拟移动捕获包括将对象移动构造为绑定对象,然后通过引⽤将对象移动构造传递给lambda。
// 3. 由于绑定对象的⽣命周期与闭包对象的⽣命周期相同,因此可以将绑定对象中的对象视为闭包中的 对象。
auto func = [pw = std::make_unique<Widget>()] //同之前一样
{ return pw->isValidated() //在闭包中创建pw

// 这是C++11的模拟实现:
auto func = std::bind(
[](const std::unique_ptr<Widget>& pw)
{ return pw->isValidated()
&& pw->isArchived(); },
std::make_unique<Widget>()
);
// 具备讽刺意味的是,这⾥我展⽰了如何使⽤ std::bind 解决C++11 lambda中的限制,但在条款34中, 我却主张在 std::bind 上使⽤lambda。 但是,该条⽬解释的是在C++11中有些情况下 std::bind 可能有⽤,这就是其中⼀种。 (在C++14中, 初始化捕获和⾃动参数等功能使得这些情况不再存在。)

rem

  • 使⽤C ++14的初始化捕获将对象移动到闭包中。
  • 在C ++11中,通过⼿写类或 std::bind 的⽅式来模拟初始化捕获。

item 33 对于std::forward的auto&&形参使⽤decltype

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
auto f = [](auto x){ return func(normalize(x)); };
// 对应的闭包类中的函数调用操作符看来就变成这样:
class SomeCompilerGeneratedClassName {
public:
template<typename T> //auto返回类型见条款3
auto operator()(T x) const
{ return func(normalize(x)); }
//其他闭包类功能
};

// 在这个样例中,*lambda*对变量`x`做的唯一一件事就是把它转发给函数`normalize`。如果函数`normalize`对待左值右值的方式不一样,这个*lambda*的实现方式就不大合适了,因为即使传递到*lambda*的实参是一个右值,*lambda*传递进`normalize`的总是一个左值(形参`x`)。
// 实现这个lambda的正确⽅式是把 x 完美转发给函数 normalize 。这样做需要对代码做两处修改。⾸ 先,x需要改成通⽤引⽤,其次,需要使⽤ std::forward 将 x 转发到函数 normalize 。实际上的修改 如下:
auto f = [](auto&& x)
{ return func(normalize(std::forward<???>(x))); };
// 在理论和实际之间存在⼀个问题:你传递给 std::forward 的参数是什么类型,就决定了上⾯的 ??? 该 怎么修改。 ⼀般来说,当你在使⽤完美转发时,你是在⼀个接受类型参数为 T 的模版函数⾥,所以你可以写 std::forward<T> 。但在泛型lambda中,没有可⽤的类型参数 T 。在lambda⽣成的闭包⾥,模版化 的 operator() 函数中的确有⼀个 T ,但在lambda⾥却⽆法直接使⽤它。
// 前⾯item28解释过在传递给通⽤引⽤的是⼀个左值,那么它会变成左值引⽤。传递的是右值就会变成右 值引⽤。这意味着在这个lambda中,可以通过检查 x 的类型来检查传递进来的实参是⼀个左值还是右 值,decltype就可以实现这样的效果。传递给lambda的是⼀个左值, decltype(x) 就能产⽣⼀个左值 引⽤;如果传递的是⼀个右值, decltype(x) 就会产⽣右值引⽤。
// Item28也解释过在调⽤ std::forward ,传递给它的类型类型参数是⼀个左值引⽤时会返回⼀个左值; 传递的是⼀个⾮引⽤类型时,返回的是⼀个右值引⽤,而不是常规的⾮引⽤。在前⾯的lambda中,如果 x绑定的是⼀个左值引⽤, decltype(x) 就能产⽣⼀个左值引⽤;如果绑定的是⼀个右值, decltype(x) 就会产⽣右值引⽤,而不是常规的⾮引⽤。
// 也就是forward<x的类型&&>(),那么这会改变forward的行为吗?
// 回顾一下cpp14下forward的实现:
template<typename T> //在std命名空间
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}
Widget&& forward(Widget& param) //当T是Widget时的std::forward实例
{
return static_cast<Widget&&>(param);
}
Widget&& && forward(Widget& param) //当T是Widget&&时的std::forward实例
{ //(引用折叠之前)
return static_cast<Widget&& &&>(param);
}
Widget&& forward(Widget& param) //当T是Widget&&时的std::forward实例
{ //(引用折叠之后)
return static_cast<Widget&&>(param);
}
// 对比这个实例和用`Widget`设置`T`去实例化产生的结果,它们完全相同。表明用右值引用类型和用非引用类型去初始化`std::forward`产生的相同的结果。

// 那是⼀个很好的消息,引⽤当传递给lambda形参x的是⼀个右值实参时, decltype(x) 可以产⽣⼀个右 值引⽤。前⾯已经确认过,把⼀个左值传给lambda时, decltype(x) 会产⽣⼀个可以传给 std::forward 的常规类型。
// 所以⽆论是左值还 是右值,把 decltype(x) 传递给 std::forward 都能得到我们想要的结果,因此lambda的完美转发可以写成:
auto f =
[](auto&& param)
{
return
func(normalize(std::forward<decltype(param)>(param)));
};
// 写成可变参类型:加上6个点
auto f =
[](auto&&... params)
{
return
func(normalize(std::forward<decltype(params)>(params)...));
};

rem

  • 对 auto&& 参数使⽤ decltype 来( std::forward )转发参数;

item 34 考虑lambda表达式而⾮std::bind

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// 因为在C ++11中, lambda ⼏ 乎是⽐ std :: bind 更好的选择。 从C++14开始, lambda 的作⽤不仅强⼤,而且是完全值得使⽤的。我们将从 std::bind 返回的函数对象称为绑定对象。 

// case1: 优先lambda而不是 std::bind 的最重要原因是lambda更易读。
// 例如,假设我们有⼀个设置闹钟的函 数
//一个时间点的类型定义(语法见条款9)
using Time = std::chrono::steady_clock::time_point;
//“enum class”见条款10
enum class Sound { Beep, Siren, Whistle };
//时间段的类型定义
using Duration = std::chrono::steady_clock::duration;
//在时间t,使用s声音响铃时长d
void setAlarm(Time t, Sound s, Duration d);
//setSoundL(“L”指代“lambda”)是个函数对象,允许指定一小时后响30秒的警报器的声音
auto setSoundL =
[](Sound s)
{
//使std::chrono部件在不指定限定的情况下可用
using namespace std::chrono;

setAlarm(steady_clock::now() + hours(1), //一小时后响30秒的闹钟
s, //译注:setAlarm三行高亮
seconds(30));
};
// 我们在lambda中突出了对 setAlarm 的调⽤。这看来起是⼀个很正常的函数调⽤,即使是⼏乎没有lambda经验的读者也可以看到:传递给lambda的参数被传递给了 setAlarm 。
// 通过使⽤基于C++11对⽤⼾⾃定义常量的⽀持而建⽴的标准后缀,如秒(s),毫秒(ms)和小时(h)等,我们 可以简化C++14中的代码。这些后缀在 std::literals 命名空间中实现,因此上述代码可以按照以下⽅ 式重写:
auto setSoundL =
[](Sound s)
{
using namespace std::chrono;
using namespace std::literals; //对于C++14后缀

setAlarm(steady_clock::now() + 1h, //C++14写法,但是含义同上
s,
30s);
};
// 我们看一下bind的写法:
using namespace std::chrono; //同上
using namespace std::literals;
using namespace std::placeholders; //“_1”使用需要

auto setSoundB = //“B”代表“bind”
std::bind(setAlarm,
steady_clock::now() + 1h, //不正确!见下
_1,
30s);
// _1是一个占位符,是setSoundB的第一个参数,被用于setAlarm的第二个参数,但正如我所说,代码并不完全正确。在lambda中,表达式 steady_clock::now() + 1h 显然是是 setAlarm 的参数。调⽤ setAlarm 时将对其进⾏计算。这是合理的:我们希望在调⽤ setAlarm 后⼀小 时发出警报。但是,在 std::bind 调⽤中,将 steady_clock::now() + 1h 作为参数传递给了 std::bind,而不是 setAlarm 。这意味着将在调⽤ std::bind 时对表达式进⾏求值,并且该表达式产⽣的时间 将存储在结果绑定对象中。结果,闹钟将被设置为在调⽤ std::bind 后⼀小时发出声⾳,而不是在调⽤ setAlarm`⼀小时后发出。
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h),
_1,
30s);
// 要解决此问题,需要告诉 std::bind 推迟对表达式的求值,直到调⽤ setAlarm 为⽌,而这样做的⽅法 是将对 std::bind 的第⼆个调⽤嵌套在第⼀个调⽤中:
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h),
_1,
30s);
// 如果你熟悉C++98的`std::plus`模板,你可能会惊讶地发现在此代码中,尖括号之间未指定任何类型,即该代码包含“`std::plus<>`”,而不是“`std::plus<type>`”。 在C++14中,通常可以省略标准运算符模板的模板类型实参,因此无需在此处提供。 C++11没有提供此类功能,因此等效于*lambda*的C++11 `std::bind`为:
using namespace std::chrono; //同上
using namespace std::placeholders;
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<steady_clock::time_point>(),
steady_clock::now(),
hours(1)),
_1,
seconds(30));
// 如果此时Lambda看起来不够吸引,那么应该检查⼀下视⼒了。
// 更进一步凸显方便:setAlarm有4参数的重载版本:
enum class Volume { Normal, Loud, LoudPlusPlus }; void setAlarm(Time t, Sound s, Duration d, Volume v);
// lambda能继续像以前⼀样使⽤,因为根据重载规则选择了 setAlarm 的三参数版本:
auto setSoundL = //和之前一样
[](Sound s)
{
using namespace std::chrono;
setAlarm(steady_clock::now() + 1h, //可以,调用三实参版本的setAlarm
s,
30s);
};
// 然而,`std::bind`的调用将会编译失败:
auto setSoundB = //错误!哪个setAlarm?
std::bind(setAlarm,
std::bind(std::plus<>(),
steady_clock::now(),
1h),
_1,
30s);
// 这⾥的问题是,编译器⽆法确定应将两个setAlarm函数中的哪⼀个传递给 std::bind 。 它们仅有的是 ⼀个函数名称,而这个函数名称是不确定的。 要获得对 std::bind 的调⽤能进⾏编译,必须将 setAlarm 强制转换为适当的函数指针类型:
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
auto setSoundB = //现在可以了
std::bind(static_cast<SetAlarm3ParamType>(setAlarm),
std::bind(std::plus<>(),
steady_clock::now(),
1h),
_1,
30s);
// 但这在 lambda 和 std::bind 的使⽤上带来了另⼀个区别。
// 在 setSoundL 的函数调⽤操作符(即lambda的闭包类对应的函数调⽤操作符)内部,对 setAlarm 的调⽤是正常的函数调⽤,编译器可以按 常规⽅式进⾏内联:
setSoundL(Sound::Siren); // body of setAlarm may be inlined here
// 但是,对 std::bind 的调⽤是将函数指针传递给 setAlarm ,这意味着在 setSoundB 的函数调⽤操作符 (即绑定对象的函数调⽤操作符)内部,对 setAlarm 的调⽤是通过⼀个函数指针。 编译器不太可能通 过函数指针内联函数,这意味着与通过 setSoundL 进⾏调⽤相⽐,通过 setSoundB 对 setAlarm的 调 ⽤,其函数不⼤可能被内联:
setSoundB(Sound::Siren); // body of setAlarm is less likely to be inlined here
// 因此,使⽤ lambda 可能会⽐使⽤ std::bind 能⽣成更快的代码。

// case2: 使用lambda更容易做复杂的事情
// 考虑一个函数:它返回其参数是否在最小值( lowVal )和最⼤值( highVal )之间的 结果,其中 lowVal 和 highVal 是局部变量:
auto betweenL =
[lowVal, highVal]
(const auto& val) //C++14
{ return lowVal <= val && val <= highVal; };
// 考虑bind版本:
using namespace std::placeholders; //同上
auto betweenB =
std::bind(std::logical_and<>(), //C++14
std::bind(std::less_equal<>(), lowVal, _1),
std::bind(std::less_equal<>(), _1, highVal));
// 我希望我们都能同意,lambda版本不仅更短,而且更易于理解和维护。

// case3: lambda能够明确知道是按照值传递还是引用,而bind是按值
// 假设我们有一个函数可以创建`Widget`的压缩副本,
enum class CompLevel { Low, Normal, High }; //压缩等级
Widget compress(const Widget& w, //制作w的压缩副本
CompLevel lev);

// 并且我们想创建一个函数对象,该函数对象允许我们指定`Widget w`的压缩级别。这种使用`std::bind`的话将创建一个这样的对象:
Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);
// 现在,当我们将 w 传递给 std::bind 时,必须将其存储起来,以便以后进⾏压缩。它存储在对象compressRateB中,但是这是如何存储的呢(是通过值还是引⽤)。之所以会有所不同,是因为如果在 对 std::bind 的调⽤与对 compressRateB 的调⽤之间修改了 w ,则按引⽤捕获的 w 将反映其更改,而 按值捕获则不会。
// 答案是它是按值捕获的,但唯⼀知道的⽅法是记住 std::bind 的⼯作⽅式;在对 std::bind 的调⽤中 没有任何迹象。与lambda⽅法相反,其中 w 是通过值还是通过引⽤捕获是显式的:
auto compressRateL = //w是按值捕获,lev是按值传递
[w](CompLevel lev)
{ return compress(w, lev); };
// 同样明确的是如何将参数传递给lambda。 在这⾥,很明显参数 lev 是通过值传递的。 因此:
compressRateL(CompLevel::High); // arg is passed by value
// 但是在对由 std::bind ⽣成的对象调⽤中,参数如何传递?
compressRateB(CompLevel::High); // how is arg passed?
// 同样,唯⼀的⽅法是记住 std::bind 的⼯作⽅式。(答案是传递给绑定对象的所有参数都是通过引⽤传 递的,因为此类对象的函数调⽤运算符使⽤完美转发。)

// 结论:与lambda相⽐,使⽤ std::bind 进⾏编码的代码可读性较低,表达能⼒较低,并且效率可能较低。 在C++14中,没有 std::bind 的合理⽤例。

// 若你只能用cpp11,bind有2种情况有效:
// 1. 移动捕获。 C++11的lambda不提供移动捕获,但是可以通过结合lambda和 std::bind 来模拟。
// 2. 多态函数对象。 因为绑定对象上的函数调⽤运算符使⽤完全转发,所以它可以接受任何类型的参数 (以条款30中描述的完全转发的限制为例⼦)。当您要使⽤模板化函数调⽤运算符来绑定对象时, 此功能很有⽤。 例如这个类,
class PolyWidget {
public:
template<typename T>
void operator()(const T& param);

};
PolyWidget pw;
auto boundPW = std::bind(pw, _1);
boundPW(1930); //传int给PolyWidget::operator()
boundPW(nullptr); //传nullptr给PolyWidget::operator()
boundPW("Rosebud"); //传字面值给PolyWidget::operator()
// 这一点无法使用C++11的*lambda*做到。 但是,在C++14中,可以通过带有`auto`形参的*lambda*轻松实现:
auto boundPW = [pw](const auto& param) //C++14
{ pw(param); };

// 最后在总结:在C ++11中增 加了lambda⽀持,这使得 std::bind ⼏乎已经过时了,从C ++ 14开始,更是没有很好的⽤例了。

rem

  • 与使⽤ std::bind 相⽐,Lambda更易读,更具表达⼒并且可能更⾼效。
  • 只有在C++11中, std::bind 可能对实现移动捕获或使⽤模板化函数调⽤运算符来绑定对象时会很 有⽤。

——- chapter 7 ——- 并发API

C++11的伟大成功之一是将并发整合到语言和库中。熟悉其他线程API(比如pthreads或者Windows threads)的开发者有时可能会对C++提供的斯巴达式(译者注:应该是简陋和严谨的意思)功能集感到惊讶,这是因为C++对于并发的大量支持是在对编译器作者约束的层面。开发者首次通过标准库可以写出跨平台的多线程程序。这为构建表达库奠定了坚实的基础,标准库并发组件(任务tasks,期望futures,线程threads,互斥mutexes,条件变量condition variables,原子对象atomic objects等)仅仅是成为并发软件开发者丰富工具集的基础。

记住标准库有两个future的模板:std::futurestd::shared_future。在许多情况下,区别不重要,所以我们经常简单的混于一谈为futures

item 35 优先基于任务编程而不是基于线程

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
// 如果开发者想要异步执⾏ doAsyncWork 函数,通常有两种⽅式。其⼀是通过创建 std::thread 执⾏ doAsyncWork , ⽐如:
int doAsyncWork();
std::thread t(doAsyncWork);
// 其⼆是将 doAsyncWork 传递给 std::async , ⼀种基于任务的策略:
auto fut = std::async(doAsyncWork); // "fut" for "future"
// 基于任务的⽅法通常⽐基于线程的⽅法更优,原因之⼀上⾯的代码已经表明,基于任务的⽅法代码量更 少。我们假设唤醒 doAsyncWork 的代码对于其提供的返回值是有需求的。基于线程的⽅法对此⽆能为 ⼒,而基于任务的⽅法可以简单地获取 std::async 返回的 future 提供的 get 函数获取这个返回值。 如果 doAsycnWork 发⽣了异常, get 函数就显得更为重要,因为 get 函数可以提供抛出异常的访问,而 基于线程的⽅法,如果 doAsyncWork 抛出了异常,线程会直接终⽌(通过调⽤ std::terminate )。

// 基于线程与基于任务最根本的区别在于抽象层次的⾼低。基于任务的⽅式使得开发者从线程管理的细节 中解放出来,对此在C++并发软件中总结了'thread'的三种含义:
// 1. 硬件线程(Hardware threads)是真实执⾏计算的线程。现代计算机体系结构为每个CPU核⼼提 供⼀个或者多个硬件线程。
// 2. 软件线程(Software threads)(也被称为系统线程)是操作系统管理的在硬件线程上执⾏的线 程。通常可以存在⽐硬件线程更多数量的软件线程,因为当软件线程被⽐如 I/O、同步锁或者条件 变量阻塞的时候,操作系统可以调度其他未阻塞的软件线程执⾏提供吞吐量。
// 3. std::threads 是C++执⾏过程的对象,并作为软件线程的handle(句柄)。 std::threads 存在多 种状态,
// 3.1. null 表⽰空句柄,因为处于默认构造状态(即没有函数来执⾏),因此不对应任何软 件线程。
// 3.2. moved from (moved-to的 std::thread 就对应软件进程开始执⾏)
// 3.3. joined (连接 唤醒与被唤醒的两个线程)
// 3.4. detached (将两个连接的线程分离)

// 软件线程是有限的资源。如果开发者试图创建⼤于系统⽀持的硬件线程数量,会抛出 std::system_error 异常。即使你编写了不抛出异常的代码,这仍然会发⽣,⽐如下⾯的代码,即使 doAsyncWork 是 noexcept
int doAsyncWork() noexcept; // see Item 14 for noexcept
// 这段代码仍然会抛出异常。
std::thread t(doAsyncWork); // throw if no more threads are available

// 设计良好的软件必须有效地处理这种可能性(软件线程资源耗尽),⼀种有效的⽅法是在当前线程执⾏ doAsyncWork ,但是这可能会导致负载不均,而且如果当前线程是GUI线程,可能会导致响应时间过⻓ 的问题;另⼀种⽅法是等待当前运⾏的线程结束之后再创建新的线程,但是仍然有可能当前运⾏的线程 在等待 doAsyncWork 的结果(例如操作得到的变量或者条件变量的通知)。
// 即使没有超出软件线程的限额,仍然可能会遇到资源超额的⿇烦。如果当前准备运⾏的软件线程⼤于硬 件线程的数量,系统的线程调度程序会将硬件核⼼的时间切⽚,当⼀个软件线程的时间⽚执⾏结束,会 让给另⼀个软件线程,即发⽣上下⽂切换。软件线程的上下⽂切换会增加系统的软件线程管理开销,并 且如果发⽣了硬件核⼼漂移,这个开销会更⾼,具体来说,如果发⽣了硬件核⼼漂移,(1)CPU cache
// 中关于上次执⾏线程的数据很少,需要重新加载指令;(2)新线程的cache数据会覆盖⽼线程的数据, 如果将来会再次覆盖⽼线程的数据,显然频繁覆盖增加很多切换开销。 避免资源超额是困难的,因为软件线程之于硬件线程的最佳⽐例取决于软件线程的执⾏频率,(⽐如⼀ 个程序从IO密集型变成计算密集型,执⾏频率是会改变的),而且⽐例还依赖上下⽂切换的开销以及软 件线程对于CPU cache的使⽤效率。此外,硬件线程的数量和CPU cache的速度取决于机器的体系结 构,即使经过调校,软件⽐例在某⼀种机器平台取得较好效果,换⼀个其他类型的机器这个调校并不能 提供较好效果的保证。
auto fut = std::async(doAsyncWork); //线程管理责任交给了标准库的开发者

// 如果考虑⾃⼰实现在等待结果的线程上运⾏输出结果的函数,之前提到了可能引出负载不均衡的问题, std::async 运⾏时的调度程序显然⽐开发者更清楚调度策略的制定,因为运⾏时调度程序管理的是所 有执⾏过程,而不仅仅个别开发者运⾏的代码。
// 最前沿的线程调度算法使⽤线程池来避免资源超额的问题,并且通过窃取算法来提升了跨硬件核⼼(频繁切换上下文,可以想象的导致cache中数据交替载入)的负 载均衡。

// 对⽐基于线程的开发⽅式,基于任务的设计为开发者避免了线程管理的痛苦,并且⾃然提供了⼀种获取 异步执⾏的结果的⽅式。当然,仍然存在⼀些场景直接使⽤ std::thread 会更有优势:
// 1. 需要访问⾮常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads 或者windows threads)来实现的,系统级API通常会提供更加灵活的操作⽅式,举个例⼦,C++并发API没有线程优先级和affinities的概念。为了提供对底层系统级线程API的访问, std::thread 对象提 供了 native_handle 的成员函数,而在⾼层抽象的⽐如 std::futures 没有这种能⼒。
// 2. 需要优化应⽤的线程使⽤。举个例⼦,只在特定系统平台运⾏的软件,可以调教地⽐使⽤C++并⾏
// 3. API更好的程序性能。 需要实现C++并发API之外的线程技术。举例来说,⾃⾏实现线程池技术。

rem

  • std::thread API不能直接访问异步执⾏的结果,如果执⾏函数有异常抛出,代码会终⽌执⾏
  • 基于线程的编程⽅式关于解决资源超限,负载均衡的⽅案移植性不佳
  • 基于任务的编程⽅式 std::async 会默认解决上⾯两条问题

item 36 必须异步执行,就指定std::launch::async

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// async有两种默认的执行方式:
// std::launch::async 的launch policy意味着f必须异步执⾏,即在不同的线程
// std::launch::deferred 的launch policy意味着f仅仅在当调⽤ get或者wait 要求 std::async 的返回值时才执⾏。这表⽰f推迟到被求值才延迟执⾏(译者注:异步与并发是两个不同概念,这 ⾥侧重于惰性求值)。当 get或wait 被调⽤,f会同步执⾏,即调⽤⽅停⽌直到f运⾏结束。如果 get和wait 都没有被调⽤,f将不会被执⾏

// 有趣的是, std::async 的默认launch policy是以上两种都不是。相反,是求或在⼀起的。下⾯的两种 调⽤含义相同
auto fut1 = std::async(f); // run f using default launch policy
auto fut2 = std::async(std::launch::async | std::launch::deferred, f); // run f either async or defered
// 因此默认策略允许f异步或者同步执⾏。如同Item 35中指出,这种灵活性允许 std::async 和标准库的 线程管理组件(负责线程的创建或销毁)避免超载。这就是使⽤ std::async 并发编程如此⽅便的原 因。
auto fut = std::async(f); // run f using default launch policy
// - ⽆法预测f是否会与t同时运⾏,因为f可能被安排延迟运⾏
// - ⽆法预测f是否会在调⽤ get或wait 的线程上执⾏。如果那个线程是t,含义就是⽆法预测f是否也在 线程t上执⾏
// - ⽆法预测f是否执⾏,因为不能确保 get或者wait 会被调⽤
// 默认启动策略的调度灵活性导致使⽤线程本地变量⽐较⿇烦,因为这意味着如果f读写了线程本地存储 (thread-local storage, TLS),不可能预测到哪个线程的本地变量被访问:
auto fut = std::async(f); // TLS for f possibly for independent thread, but possibly for thread invoking get or wait on fut
// 还会影响到基于超时机制的wait循环,因为在task的 wait_for 或者 wait_until 调⽤中会产⽣延迟求值(当wait或者get被调用才异步执行函数)( std::launch::deferred )。意味着,以下循环看似应该终⽌,但是实际上永 远运⾏:
using namespace std::literals; //为了使用C++14中的时间段后缀;参见条款34

void f() //f休眠1秒,然后返回
{
std::this_thread::sleep_for(1s);
}

auto fut = std::async(f); //异步运行f(理论上)

while (fut.wait_for(100ms) != //循环,直到f完成运行时停止...
std::future_status::ready) //但是有可能永远不会发生!
{

}
// 如果f与调⽤ std::async 的线程同时运⾏(即,如果为f选择的启动策略是 std::launch::async ), 这⾥没有问题(假定f最终执⾏完毕),但是如果f是延迟执⾏, fut.wait_for 将总是返回 std::future_status::deferred 。这表⽰循环会永远执⾏下去。
// 这种错误很容易在开发和单元测试中忽略,因为它可能在负载过⾼时才能显现出来。当机器负载过重 时,任务推迟执⾏才最有可能发⽣。毕竟,如果硬件没有超载,没有理由不安排任务并发执⾏。
// 修复也是很简单的:只需要检查与 std::async 的future是否被延迟执⾏即可,那样就会避免进⼊⽆限 循环。不幸的是,没有直接的⽅法来查看future是否被延迟执⾏。相反,你必须调⽤⼀个超时函数----⽐ 如 wait_for 这种函数。在这个逻辑中,你不想等待任何事,只想查看返回值是否 std::future_status::deferred ,如果是就使⽤0调⽤ wait_for 来终⽌循环。
auto fut = std::async(f); //同上

if (fut.wait_for(0s) == //如果task是deferred(被延迟)状态
std::future_status::deferred)
{
//在fut上调用wait或get来异步调用f
} else { //task没有deferred(被延迟)
while (fut.wait_for(100ms) != //不可能无限循环(假设f完成)
std::future_status::ready) {
//task没deferred(被延迟),也没ready(已准备)
//做并行工作直到已准备
}
//fut是ready(已准备)状态
}

// 这些各种考虑的结果就是,只要满⾜以下条件, std::async 的默认启动策略就可以使⽤:
// - 任务不需要和执行`get`或`wait`的线程并行执行。
// - 读写哪个线程的`thread_local`变量没什么问题。
// - 可以保证会在`std::async`返回的*future*上调用`get`或`wait`,或者该任务可能永远不会执行也可以接受。
// - 使用`wait_for`或`wait_until`编码时考虑到了延迟状态。
// 如果上述条件任何⼀个都满⾜不了,你可能想要保证 std::async 的任务真正的异步执⾏。进⾏此操作 的⽅法是调⽤时,将 std::launch::async 作为第⼀个参数传递:
auto fut = std::async(std::launch::async, f); // launch f asynchronously
事实上,具有类似 std::async ⾏为的函数,但是会⾃动使⽤ std::launch::async 作为启动策略的⼯ 具也是很容易编写的,C++11\14版本如下:
template<typename F, typename... Ts> // cpp11
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&... params) //返回异步调用f(params...)得来的future
{
return std::async(std::launch::async,
std::forward<F>(f),
std::forward<Ts>(params)...);
}
template<typename F, typename... Ts>
inline
auto // C++14
reallyAsync(F&& f, Ts&&... params)
{
return std::async(std::launch::async,
std::forward<F>(f),
std::forward<Ts>(params)...);
}
// 这个版本清楚表明,reallyAsync 除了使⽤ std::launch::async 启动策略之外什么也没有做。

rem

  • std::async 的默认启动策略是异步或者同步的
  • 灵活性导致访问thread_locals的不确定性,隐含了task可能不会被执⾏的意思,会影响程序基于 wait 的超时逻辑
  • 只有确实异步时才指定 std::launch::async

item 37 确保std::threads在所有路径都不可join(也就是确保都被join过了)

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 每个 std::thread 对象处于两个状态之⼀:joinable or unjoinable。joinable状态的 std::thread 对应 于正在运⾏或者可能正在运⾏的异步执⾏线程。⽐如,⼀个blocked或者等待调度的 std::thread 是joinable,已运⾏结束的 std::thread 也可以认为是joinable
// unjoinable的 std::thread 对象⽐如:(译者注: std::thread 可以视作状态保存的对象,保存的状态可能也包括可调⽤对象,有没有具体的 线程承载就是有没有连接)
// - **默认构造的`std::thread`s**。这种`std::thread`没有函数执行,因此没有对应到底层执行线程上。
// - **已经被移动走的`std::thread`对象**。移动的结果就是一个`std::thread`原来对应的执行线程现在对应于另一个`std::thread`。
// - **已经被`join`的`std::thread`** 。在`join`之后,`std::thread`不再对应于已经运行完了的执行线程。
// - **已经被`detach`的`std::thread`** 。`detach`断开了`std::thread`对象与执行线程之间的连接。
constexpr auto tenMillion = 10000000; //constexpr见条款15
bool doWork(std::function<bool(int)> filter, //返回计算是否执行;
int maxVal = tenMillion) //std::function见条款2
{
std::vector<int> goodVals; //满足filter的值

std::thread t([&filter, maxVal, &goodVals] //填充goodVals
{
for (auto i = 0; i <= maxVal; ++i)
{ if (filter(i)) goodVals.push_back(i); }
});

auto nh = t.native_handle(); //使用t的原生句柄
//来设置t的优先级

if (conditionsAreSatisfied()) {
t.join(); //等t完成
performComputation(goodVals);
return true; //执行了计算
}
return false; //未执行计算
}
// 返回 doWork 。如果 conditionsAreSatisfied() 返回真,没什么问题,但是如果返回假或者抛出异 常, std::thread 类型的 t 在 doWork 结束时会调⽤ t 的析构器。这造成程序执⾏中⽌。
// 假设不是终止程序,而是以下两种情况:
// 1. 隐式join: doWork返回前,执行t的析构,那么do work会一直无法返回,由于dowork需要等待t的函数执行完,这是违反直觉的
// 2. 隐式detach: doWork前,t直接和他之前运行的函数detach,那么在栈区的数据会被这个被t detach过的还在运行的函数不断修改,然后如果有其他程序会读这片内存,你可以想象这种调试emmm

// 标准委员会认为,销毁连接中的线程如此可怕以⾄于实际上禁⽌了它(通过指定销毁连接中的线程导致 程序终⽌) 这使你有责任确保使⽤ std::thread 对象时,在所有的路径上最终都是unjoinable的。但是覆盖每条路 径可能很复杂,可能包括 return, continue, break, goto or exception ,有太多可能的路径。
// 每当你想每条路径的块之外执⾏某种操作,最通⽤的⽅式就是将该操作放⼊本地对象的析构函数中。这 些对象称为RAII对象,通过RAII类来实例化。(RAII全称为 Resource Acquisition Is Initialization)。RAII类在标准库中很常⻅。⽐如STL容器,智能指针, std::fstream 类等。但是标准库没有RAII的 std::thread 类,可能是因为标准委员会拒绝将 join和detach 作为默认选项,不知道应该怎么样完成RAII。 幸运的是,完成⾃⾏实现的类并不难。⽐如,下⾯的类实现允许调⽤者指定析构函数 join或者 detach :
class ThreadRAII {
public:
enum class DtorAction { join, detach }; //enum class的信息见条款10

ThreadRAII(std::thread&& t, DtorAction a) //析构函数中对t实行a动作
: action(a), t(std::move(t)) {}

~ThreadRAII()
{ //可结合性测试见下
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
// Item 17说明因为 ThreadRAII 声明了⼀个析构函数,因此不会有编译器⽣成移动操作,但是没有理由 ThreadRAII 对象不能移动。所以需要我们显式声明来告诉编译器⾃动⽣成:
ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;
std::thread& get() { return t; } //见下

private:
DtorAction action;
std::thread t;
};
// - 构造器只接受 std::thread 右值,因为我们想要move std::thread 对象给 ThreadRAII (再次 强调, std::thread 不可以复制)
// - 构造器的参数顺序设计的符合调⽤者直觉(⾸先传递 std::thread ,然后选择析构执⾏的动 作),但是成员初始化列表设计的匹配成员声明的顺序。将 std::thread 成员放在声明最后。在 这个类中,这个顺序没什么特别之处,调整为其他顺序也没有问题,但是通常,可能⼀个成员的初 始化依赖于另⼀个,因为 std::thread 对象可能会在初始化结束后就⽴即执⾏了,所以在最后声 明是⼀个好习惯。这样就能保证⼀旦构造结束,所有数据成员都初始化完毕可以安全的异步绑定线 程执⾏
// - ThreadRAII 提供了 get 函数访问内部的 std::thread 对象
// - 在 ThreadRAII 析构函数调⽤ std::thread 对象t的成员函数之前,检查t是否joinable。这是必须 的,因为在unjoinbale的 std::thread 上调⽤ join or detach 会导致未定义⾏为。客⼾端可能 会构造⼀个 std::thread t,然后通过t构造⼀个 ThreadRAII ,使⽤ get 获取t,然后移动t,或者 调⽤ join or detach ,每⼀个操作都使得t变为unjoinable
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
// 存在竞争,因为在 t.joinable() 和 t.join or t.detach 执⾏中间,可能有其他线程改变了t为
// unjoinable,你的态度很好,但是这个担⼼不必要。std::thread 只有⾃⼰可以改变 joinable or unjoinable 的状态。在 ThreadRAII 的析构函数中被调⽤时,其他线程不可能做成员函数的调 ⽤。如果同时进⾏调⽤,那肯定是有竞争的,但是不在析构函数中,是在客⼾端代码中试图同时在 ⼀个对象上调⽤两个成员函数(析构函数和其他函数)。通常,仅当所有都为const成员函数时, 在⼀个对象同时调⽤两个成员函数才是安全的。

rem

  • 在所有路径上保证 thread 最终是unjoinable
  • 析构时 join 会导致难以调试的性能异常问题
  • 析构时 detach 会导致难以调试的未定义⾏为
  • 声明类数据成员时,最后声明 std::thread 类型成员(因为最终声明thread可以保证这个变量最后初始化)

item 38 明白不同线程句柄的析构⾏为

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 可以将 std::thread 对象和 future 对象都视作系统线 程的句柄。
// 这里给出future使用的具体示例:
#include <iostream>
#include <future>
#include <thread>

int main()
{
// future from a packagzed_task
std::packaged_task<int()> task([]{ return 7; }); // wrap the function
std::future<int> f1 = task.get_future(); // get a future
std::thread t(std::move(task)); // launch on a thread

// future from an async()
std::future<int> f2 = std::async(std::launch::async, []{ return 8; });

// future from a promise
std::promise<int> p;
std::future<int> f3 = p.get_future();
std::thread( [&p]{ p.set_value_at_thread_exit(9); }).detach();

std::cout << "Waiting..." << std::flush;
f1.wait();
f2.wait();
f3.wait();
std::cout << "Done!\nResults are: "
<< f1.get() << ' ' << f2.get() << ' ' << f3.get() << '\n';
t.join();
}

// 但是被调⽤者的结果存储在哪⾥?
// 被调⽤者会在调⽤者 get 相关的 future 之前执⾏完成,所以结果不 能存储在被调⽤者的 std::promise 。这个对象是局部的,当被调⽤者执⾏结束后,会被销毁。
// 结果同样不能存储在调⽤者的 future ,因为 std::future 可能会被⽤来创建 std::shared_future (这会将被调⽤者的结果所有权从 std::future 转移给 std::shared_future ), 而 std::shared_future 在 std::future 被销毁之后被复制很多次。鉴于不是所有的结果都可以被拷 ⻉(有些只能移动)和结果的声明周期与最后⼀个引⽤它的 future ⼀样⻓,哪个才是被调⽤者⽤来存 储结果的?
// 因为与被调⽤者关联的对象和调⽤者关联的对象都不适合存储这个结果,必须存储在两者之外的位置。 此位置称为共享状态(shared state)。共享状态通常是基于堆的对象,但是标准并未指定其类型、接口 和实现。标准库的作者可以通过任何他们喜欢的⽅式来实现共享状态。
// Non-defered任务(启动参数为 std::launch::async )的最后⼀个关联共享状态的 future 析构 函数会在任务完成之前block住。本质上,这种 future 的析构对执⾏异步任务的线程做了隐式的 join 。
// future 其他对象的析构简单的销毁。对于异步执⾏的任务,就像对底层的线程执⾏ detach 。对 于defered任务的最后⼀种 future ,意味着这个defered任务永远不会执⾏了。

// 这些规则听起来好复杂。我们真正要处理的是⼀个简单的“正常”⾏为以及⼀个单独的例外。正常⾏为是 future 析构函数销毁 future 。那意味着不 join 也不 detach ,只销毁 future 的数据成员(当然,还 做了另⼀件事,就是对于多引⽤的共享状态引⽤计数减⼀。) 正常⾏为的例外情况仅在同时满⾜下列所有情况下才会执⾏:
// - 关联 future 的共享状态是被调⽤了 std::async 创建的
// - 任务的启动策略是 std::launch::async (参⻅Item 36),原因是运⾏时系统选择了该策略,或 者在对 std::async 的调⽤中指定了该策略。
// - future 是关联共享状态的最后⼀个引⽤。对于 std::future ,情况总是如此,对于 std::shared_future ,如果还有其他的 std::shared_future 引⽤相同的共享状态没有销毁, 就不是。
// 只有当上⾯的三个条件都满⾜时, future 的析构函数才会表现“异常”⾏为,就是在异步任务执⾏完之前block住。实际上,这相当于运⾏ std::async 创建的任务的线程隐式 join 。

// 如果你有办法知道给定的 future 不满⾜上⾯条件的任意⼀条,你就可以确定析构函数不会执⾏ “异常”⾏为。⽐如,只有通过 std::async 创建的共享状态才有资格执⾏“异常”⾏为,但是有其他创建共 享状态的⽅式。⼀种是使⽤ std::packaged_task ,⼀个 std::packaged_task 对象准备⼀个函数(或 者其他可调⽤对象)来异步执⾏,然后将其结果放⼊共享状态中。然后通过 std::packaged_task 的 get_future 函数获取有关该共享状态的信息:
{ // begin block
std::packaged_task<int()> pt(calcValue);
auto fut = pt.get_future();
std::thread t(std::move(pt));
...
} // end block

// 此处最有趣的代码是在创建 std::thread 对象t之后的"..."。"..."有三种可能性:
// - 对t不做什么。这种情况,t会在语句块结束joinable,这会使得程序终⽌(参⻅Item 37)
// - 对t调⽤ join 。这种情况,不需要fut的析构函数block,因为 join 被显式调⽤了
// - 对t调⽤ detach 。这种情况,不需要在fut的析构函数执⾏ detach ,因为显式调⽤了


rem

  • future 的正常析构⾏为就是销毁 future 本⾝的成员数据
  • 最后⼀个引⽤ std::async 创建共享状态的 future 析构函数会在任务结束前block

item 39 对于一次性通讯使用返回void的futures

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// 有一个检测任务,检测到什么然后通知反应任务去执行,使用条件变量完成:

// 检测:
std::condition_variable cv; // condvar for event
std::mutex m; // mutex for use with cv
... // detect event
cv.notify_one(); // tell reacting task
// 如果有多个反应任务需要被通知,使⽤ notify_all()代替notify_one() ,但是这⾥,我们假定只有⼀ 个反应任务需要通知。

// 反应任务:
//反应的准备工作
{ //开启关键部分
std::unique_lock<std::mutex> lk(m); //锁住互斥锁
cv.wait(lk); //等待通知,但是这是错的!
//对事件进行反应(m已经上锁)
} //关闭关键部分;通过lk的析构函数解锁m
//继续反应动作(m现在未上锁)
// 有时候有明显的执行顺序,也就是先检查,后执行,就不存在数据竞争,但是我们使用了这个互斥变量,这是问题其一
// 还有2个问题:
// - 如果检测任务在反应任务 wait 之前通知条件变量,反应任务会挂起。为了能使条件变量唤醒另⼀ 个任务,任务必须等待在条件变量上。如果检测任务在反应任务 wait 之前就通知了条件变量,反 应任务就会丢失这次通知,永远不被唤醒
// - 线程API的存在⼀个事实(不只是C++)即使条件变量没有被通知,也可能被 虚假唤醒,这种唤醒被称为spurious wakeups。正确的代码通过确认条件变量进⾏处理,并将其作 为唤醒后的第⼀个操作。C++条件变量的API使得这种问题很容易解决,因为允许lambda(或者其 他函数对象)来测试等待条件。因此,可以将反应任务这样写:cv.wait(lk, [] { return whether the evet has occurred; });

// 在很多情况下,使⽤条件变量进⾏任务通信⾮常合适,但是也有不那么合适的情况。
// 看一个反例:
std::atomic<bool> flag(false); // shared flag; see Item 40 for std::atomic
... // detect event
flag = true; // tell reacting task

... // prepare
while(!flag); // wait for event
... // react to event
// while会一直占用CPU!

// 将条件变量和flag的设计组合起来很常⽤。⼀个flag表⽰是否发⽣了感兴趣的事件,但是通过互斥锁同步 了对该flag的访问。因为互斥锁阻⽌并发该flag,所以如Item 40所述,不需要将flag设置为 std::atomic 。⼀个简单的bool类型就可以,检测任务代码如下:
// 检测:
std::condition_variable cv; //跟之前一样
std::mutex m;
bool flag(false); //不是std::atomic
//检测某个事件
{
std::lock_guard<std::mutex> g(m); //通过g的构造函数锁住m
flag = true; //通知反应任务(第1部分)
} //通过g的析构函数解锁m
cv.notify_one(); //通知反应任务(第2部分)
// 反应:
//准备作出反应
{ //跟之前一样
std::unique_lock<std::mutex> lk(m); //跟之前一样
cv.wait(lk, [] { return flag; }); //使用lambda来避免虚假唤醒
//对事件作出反应(m被锁定)
}
//继续反应动作(m现在解锁)
// 这种⽅案是可以⼯作的,但是不太优雅。用条件变量和flag两个东西作为桥梁,确实够麻烦的
// ⼀个替代⽅案是让反应任务通过在检测任务设置的future上 wait 来避免使⽤条件变量,互斥锁和flag。Item 38中说明了future代表了从被调⽤⽅(通常是异步的)到 调⽤⽅的通信的接收端,也说说明了发送端是个`std::promise`,接收端是个*future*的通信信道不是只能被用在调用-被调用场景,这样的通信信 道可以被在任何你需要从程序⼀个地⽅传递到另⼀个地⽅的场景。
// ⽅案很简单。检测任务有⼀个 std::promise 对象(通信信道的写⼊),反应任务有对应的 std::future (通信信道的读取)。当反应任务看到事件已经发⽣,设置 std::promise 对象(写⼊到 通信信道)。同时,反应任务在 std::future 上等待。 wait 会锁住反应任务直到 std::promise 被设 置。
// 现在, std::promise和futures(std::future and std::shared_future) 都是需要参数类型的模 板我们需要的类型是表明在 std::promise 和 futures 之间没有数据被传递。所以选择 void 。
// 检测:
std::promise<void> p; //通信信道的promise
//检测某个事件
p.set_value(); //通知反应任务
// 反应:
//准备作出反应
p.get_future().wait(); //等待对应于p的那个future
//对事件作出反应
// 基于 future 的⽅法没有了上述问题,但是有其他新的问题。⽐如,Item 38中说明, std::promise 和 future 之间有共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产⽣基于堆的分配和释放开销。也许更重要的是, std::promise 只能设置⼀次。 std::promise 与 future 之间的通信是⼀次性的: 不能重复使⽤。这是与基于条件变量或者flag的明显差异,条件变量可以被重复通知,flag也可以重复清 除和设置。

// 假定你想创建⼀个挂起的线程以避免想要使⽤⼀个线程执⾏ 程序的时候的线程创建的开销。或者你想在线程运⾏前对其进⾏设置,包括优先级和core affinity。C++并发API没有提供这种设置能⼒,但是提供了 native_handle() 获取原始线程的接口(通常获取的 是POXIC或者Windows的线程),这些低层次的API使你可以对线程设置优先级和 core affinity。 假设你仅仅想要挂起⼀次线程(在创建后,运⾏前),使⽤ void future 就是⼀个⽅案。代码如下:
std::promise<void> p;
void react(); //反应任务的函数
void detect()
{
ThreadRAII tr( //使用RAII对象
std::thread([]
{
p.get_future().wait();
react();
}),
ThreadRAII::DtorAction::join //有危险!(见下)
);
//tr中的线程在这里被挂起
p.set_value(); //解除挂起tr中的线程

}
// 问题在于第⼀个"..."区域(注释了thread inside tr is suspended here),如果异 常发⽣, p.set_value() 永远不会调⽤,这意味着 lambda中的wait 永远不会返回,即lambda不会结 束,问题就是,因为RAII对象tr再析构函数中join。
// 解决方案:http://scottmeyers.blogspot.com/2013/12/threadraii-thread-suspension-trouble.html,将p和thread一起包装一下放在一个类里面,那么如果发生异常,在类的析构函数中会调用set_value,让线程正确结束
// 这⾥,我只想展⽰如何扩 展原始代码(不使⽤RAII类)使其挂起然后取消挂起,这不仅是个例,是个通⽤场景。简单概括,关键 就是在反应任务的代码中使⽤ std::shared_future 代替 std::future。 ⼀旦你知道 std::future 的 share 成员函数将共享状态所有权转移到 std::shared_future 中,代码⾃然就写出来了。唯⼀需要注 意的是,每个反应线程需要处理⾃⼰的 std::shared_future 副本,该副本引⽤共享状态,因此通过 share 获得的 shared_future 要被lambda按值捕获:
std::promise<void> p; //跟之前一样
void detect() //现在针对多个反映线程
{
auto sf = p.get_future().share(); //sf的类型是std::shared_future<void>
std::vector<std::thread> vt; //反应线程容器
for (int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{ sf.wait(); //在sf的局部副本上wait;
react(); }); //emplace_back见条款42
}
//如果这个“…”抛出异常,detect挂起!
p.set_value(); //所有线程解除挂起

for (auto& t : vt) { //使所有线程不可结合;
t.join(); //“auto&”见条款2
}
}
// 使用*future*的设计可以实现这个功能值得注意,这也是你应该考虑将其应用于一次通信的原因。

rem

  • 对于简单的事件通信,条件变量需要⼀个多余的互斥锁,对检测和反应任务的相对进度有约束,并 且需要反应任务来验证事件是否已发⽣
  • 基于flag的设计避免的上⼀条的问题,但是不是真正的挂起反应任务
  • 组合条件变量和flag使⽤,上⾯的问题都解决了,但是逻辑不让⼈愉快
  • 使⽤ std::promise和future 的⽅案,要考虑堆内存的分配和销毁开销,同时有只能使⽤⼀次通信 的限制

item 40 当需要并发时使⽤ std::atomic ,特定内存才使⽤ volatile

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// ⼀旦 std::atomic 对象被构建,在其上的操作使⽤特定的机器指令实现,这⽐锁的 实现更⾼效。分析如下使⽤ std::atmoic 的代码:
std::atomic<int> ai(0); //初始化ai为0
ai = 10; //原子性地设置ai为10
std::cout << ai; //原子性地读取ai的值
++ai; //原子性地递增ai到11
--ai; //原子性地递减ai到10
// ⾸先,在 std::cout << ai; 中, std::atomic 只保证了对 ai 的读取时 原⼦的。没有保证语句的整个执⾏是原⼦的,这意味着在读取 ai 与将其通过 ≤≤ 操作符写⼊到标准输出 之间,另⼀个线程可能会修改 ai 的值。这对于这个语句没有影响,因为 << 操作符是按值传递参数的 (所以输出就是读取到的 ai 的值),但是重要的是要理解原⼦性的范围只保证了读取是原⼦的。
// 第⼆点值得注意的是最后两条语句---关于 ai 的加减。他们都是 read-modify-write(RMW)操作,各⾃ 原⼦执⾏。这是 std::atomic 类型的最优的特性之⼀:⼀旦 std::atomic 对象被构建,所有成员函 数,包括RMW操作,对于其他线程来说保证原⼦执⾏。

// 相反,使用`volatile`在多线程中实际上不保证任何事情:
volatile int vi(0); //初始化vi为0
vi = 10; //设置vi为10
std::cout << vi; //读vi的值
++vi; //递增vi到11
--vi; //递减vi到10
// 代码的执⾏过程中,如果其他线程读取 vi ,可能读到任何值,⽐如-12,68,4090727。这份代码就是 未定义的,因为这⾥的语句修改 vi ,同时其他线程读取,这就是有没有 std::atomic 或者互斥锁保护 的对于内存的同时读写,这就是数据竞争的定义。

// 假定⼀个任务计算第⼆个 任务需要的重要值。当第⼀个任务完成计算,必须传递给第⼆个任务。Item 39表明⼀种使⽤ std::atomic<bool> 的⽅法来使第⼀个任务通知第⼆个任务计算完成。代码如下
std::atomic<bool> valVailable(false);
auto imptValue = coputeImportantValue(); // compute value
valAvailable = true; // tell other task it's vailable
// ⼈类读这份代码,能看到在 valAvailable 赋值true之前对 imptValue 赋值是重要的顺序,但是所有编 译器看到的是⼀对没有依赖关系的赋值操作。通常来说,编译器会被允许重排这对没有关联的操作。这 意味着,给定如下顺序的赋值操作:
a = b; x = y;
// 编译器可能重排为如下顺序:
x = y; a = b;
// 即使编译器没有重排顺序,底层硬件也可能重排,因为有时这样代码执⾏更快。std::atomic 会限制这种重排序,并且这样的限制之⼀是,在源代码中,对 std::atomic 变量 写之前不会有任何操作。这意味对我们的代码
auto impatValue = computeImportantValue();
valVailable = true;
// 编译器不仅要保证赋值顺序,还要保证⽣成的硬件代码不会改变这个顺序。结果就是,将 valAvaliable 声明为 std::atomic 确保了必要的顺序---- 其他线程看到 imptValue 值保证 valVailable 设为true之后。

// 声明为 volatile 不能保证上述顺序:
volatile bool valAvaliable(false);
auto imptValue = computeImportantValue();
valAvailable = true;
// 这份代码编译器可能将赋值顺序对调,也可能在⽣成机器代码时,其他核⼼看到 valVailable 更改在 imptValue 之前。

// 这种有话讲仅仅在内存表现正常时有效。“特殊”的内存不⾏。最常⻅的“特殊”内存是⽤来mapped I/O的内存。这种内存实际上是与外围设备(⽐如外部传感器或者显⽰器,打印机,⽹络端口) 通信,而不是读写(⽐如RAM)。这种情况下,再次考虑多余的代码:
auto y = x; // read x
y = x; // read x again
// 如果x的值是⼀个温度传感器上报的,第⼆次对于x的读取就不是多余的,因为温度可能在第⼀次和第⼆ 次读取之间变化。类似的,写也是⼀样: x = 10; x = 20;
// 如果x与⽆线电发射器的控制端口关联,则代码时控制⽆线电,10和20意味着不同的指令。优化会更改 第⼀条⽆线电指令。
// volatile 是告诉编译器我们正在处理“特殊”内存。意味着告诉编译器“不要对这块内存执⾏任何优化”。 所以如果x对应于特殊内存,应该声明为 volatile :
volatile int x;
auto y = x;
y = x; // can't be optimized away
x = 10; // can't be optimized away
x = 20;
// 在处理特殊内存时,必须保留看似多余的读取或者⽆效存储的事实,顺便说明了为什么 std::atomic 不 适合这种场景。 std::atomic 类型允许编译器消除此类冗余操作。代码的编写⽅式与使⽤ volatile 的 ⽅式完全不同,但是如果我们暂时忽略它,只关注编译器执⾏的操作,则可以说,
std::atomic<int> x;
auto y = x; //概念上会读x(见下)
y = x; //概念上会再次读x(见下)
x = 10; //写x
x = 20; //再次写x
// 原则上,编译器可能会优化为:
auto y = x; // conceptually read x
x = 20; // write x
// 对于特殊内存,显然这是不可接受的

// 现在,就当他没有优化了,但是对于x是 std::atomic<int> 类型来说,下⾯的两条语句都编译不通 过。
auto y = x; // error
y = x; // error
// 这是因为 std::atomic 类型的拷⻉操作时被删除的(参⻅Item 11)。想象⼀下如果y使⽤x来初始化会 发⽣什么。因为x是 std::atomic 类型,y的类型被推导为 std::atomic (参⻅Item 2)。我之前说了 std::atomic 最好的特性之⼀就是所有成员函数都是原⼦的,但是为了执⾏从x到y的拷⻉初始化是原⼦ 的,编译器不得不⽣成读取x和写⼊x为原⼦的代码。硬件通常⽆法做到这⼀点,因此 std::atomic 不⽀ 持拷⻉构造。处于同样的原因,拷⻉赋值也被delete了,这也是为什么从x赋值给y也编译失败。(移动 操作在 std::atomic 没有显式声明,因此对于Item 17中描述的规则来看, std::atomic 既不提移动构 造器也不提供移动赋值能⼒)。
// 可以将x的值传递给y,但是需要使⽤ std::atomic 的 load和store 成员函数。 load 函数原⼦读取, store 原⼦写⼊。要使⽤x初始化y,然后将x的值放⼊y,代码应该这样写:
std::atomic<int> y(x.load());
y.store(x.load());
// 给出的代码,编译器可以通过存储x的值到寄存器代替读取两次来“优化”:
register = x.load(); //把x读到寄存器
std::atomic<int> y(register); //使用寄存器值初始化y
y.store(register); //把寄存器值存储到y
// 结果如你所⻅,仅读取x⼀次,这是对于特殊内存必须避免的优化(这种优化不允许对 volatile 类型值 执⾏)。
// 事情越辩越明:
// - std::atomic ⽤在并发程序中
// - volatile ⽤于特殊内存场景

// 因为 std::atomic 和 volatile ⽤于不同的⽬的,所以可以结合起来使⽤:
volatile std::atomic<int> vai; // operations on vai are atomic and can't be optimized away
// 这可以⽤在⽐如 vai 变量关联了memory-mapped I/O内存并且⽤于并发程序的场景。

// 最后⼀点,⼀些开发者尤其喜欢使⽤ std::atomic 的 load 和 store 函数即使不必要时,因为这在代码 中显式表明了这个变量不“正常”。强调这⼀事实并⾮没有道理。因为访问 std::atomic 确实会更慢⼀ 些,我们也看到了 std::atomic 会阻⽌编译器对代码执⾏顺序重排。调⽤ load 和 store 可以帮助识别 潜在的可扩展性瓶颈。从正确性的⻆度来看,没有看到在⼀个变量上调⽤ store 来与其他线程进⾏通信 (⽐如flag表⽰数据的可⽤性)可能意味着该变量在声明时没有使⽤ std::atomic 。这更多是习惯问 题,但是,⼀定要知道 atomic 和 volatile 的巨⼤不同。

rem

  • std::atomic是⽤在不使⽤锁,来使变量被多个线程访问。是⽤来编写并发程序的
  • volatile 是⽤在特殊内存的场景中,避免被编译器优化内存。

——– chapter 8 ——– 微调

item 41 如果参数可拷⻉并且移动操作开销很低,总是考虑 直接按值传递

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 当你的传值代价很小,传值吧!
class Widget {
public:
template<typename T> //接受左值和右值;
void addName(T&& newName) { //拷贝左值,移动右值;
names.push_back(std::forward<T>(newName)); //std::forward的使用见条款25
}

};
// 这减少了源代码的维护⼯作,但是通⽤引⽤会导致其他复杂性。作为模板, addName 的实现必须放置在 头⽂件中。在编译器展开的时候,可能会不⽌为左值和右值实例化为多个函数,也可能为 std::string 和可转换为 std::string 的类型分别实例化为多个函数(参考Item25)。同时有些参数类型不能通过通⽤引⽤传递(参考Item30),而且如果传递了不合法的参数类型,编译器错误会令⼈⽣畏。(参考Item27)
// 是否存在⼀种编写 addName 的⽅法(左值拷⻉,右值移动),而且源代码和⽬标代码中都只有⼀个函 数,避免使⽤通⽤模板这种特性?答案是是的。你要做的就是放弃你学习C++编程的第⼀条规则,就是 ⽤⼾定义的对象避免传值。像是 addName 函数中的 newName 参数,按值传递可能是⼀种完全合理的策 略。
class Widget {
public:
void addName(std::string newName) { //接受左值或右值;移动它
names.push_back(std::move(newName));
}

}

Widget w;

std::string name("Bart");
w.addName(name); //使用左值调用addName

w.addName(name + "Jenne"); //使用右值调用addName(见下)

// 分别考虑虑三种实现中,两种调⽤⽅式,拷⻉和移动操作的开销。会忽略编译器对于移动和拷⻉操作 的优化。
class Widget { //方法1:对左值和右值重载
public:
void addName(const std::string& newName)
{ names.push_back(newName); } // rvalues
void addName(std::string&& newName)
{ names.push_back(std::move(newName)); }

private:
std::vector<std::string> names;
};

class Widget { //方法2:使用通用引用
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }

};

class Widget { //方法3:传值
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }

};
// - Overloading(重载):⽆论传递左值还是传递右值,调⽤都会绑定到⼀种 newName 的引⽤实现 ⽅式上。拷⻉和复制零开销。左值重载中, newName 拷⻉到 Widget::names 中,右值重载中,移 动进去。开销总结:左值⼀次拷⻉,右值⼀次移动。
// - Using a universal reference(通⽤模板⽅式):同重载⼀样,调⽤也绑定到 addName 的引⽤实 现上,没有开销。由于使⽤了 std::forward ,左值参数会复制到 Widget::names ,右值参数移 动进去。开销总结同重载⽅式。
// - Passing by value(按值传递):⽆论传递左值还是右值,都必须构造 newName 参数。如果传递 的是左值,需要拷⻉的开销,如果传递的是右值,需要移动的开销。在函数的实现中, newName 总 是采⽤移动的⽅式到 Widget::names 。开销总结:左值参数,⼀次拷⻉⼀次移动,右值参数两次 移动。对⽐按引动传递的⽅法,对于左值或者右值,均多出⼀次移动操作

// 总是考虑直接按值传递,如果参数可拷⻉并且移动操作开销很低,原因如下:
// 1. 应该仅consider using pass by value。是的,因为只需要编写⼀个函数,同时只会在⽬标代码中⽣ 成⼀个函数。避免了通⽤引⽤⽅式的种种问题。
// 2. 仅考虑对于可拷贝参数按值传递。不符合此条件的的参数必须只有移动构造函数。回忆⼀ 下“重载”⽅案的问题,就是必须编写两个函数来分别处理左值和右值,如果参数没有拷⻉构造函 数,那么只需要编写右值参数的函数,重载⽅案就搞定了。
class Widget {
public:

void setPtr(std::unique_ptr<std::string>&& ptr)
{ p = std::move(ptr); }

private:
std::unique_ptr<std::string> p;
};


// 调用者可能会这样写:
Widget w;

w.setPtr(std::make_unique<std::string>("Modern C++"));
// 这样,从`std::make_unique`返回的右值`std::unique_ptr<std::string>`通过右值引用被传给`setPtr`,然后移动到数据成员`p`中。整体开销就是一次移动。
// 如果`setPtr`使用传值方式接受形参:
class Widget {
public:

void setPtr(std::unique_ptr<std::string> ptr)
{ p = std::move(ptr); }

};
// 同样的调用就会先移动构造`ptr`形参,然后`ptr`再移动赋值到数据成员`p`,整体开销就是两次移动——是“重载”方法开销的两倍。
// 3. 按值传递应该仅应⽤于哪些cheap to move的参数。当移动的开销较低,额外的⼀次移动才能被开 发者接受,但是当移动的开销很⼤,执⾏不必要的移动类似不必要的复制时,这个规则就不适⽤ 了。
// 4. 你应该只对always copied(肯定复制)的参数考虑按值传递。为了看清楚为什么这很重要,假定在 复制参数到 names 容器前, addName 需要检查参数的⻓度是否过⻓或者过短,如果是,就忽略增 加 name 的操作,那么拷贝到参数的那一次拷贝就被白费了,然而引用会节省这一次拷贝
// 所以,正如我所说,当参数通过赋值进⾏拷⻉时,分析按值传递的开销是复杂的。通常,最有效的经验 就是“在证明没问题之前假设有问题”,就是除⾮已证明按值传递会为你需要的参数产⽣可接受开销的执 ⾏效率,否则使⽤重载或者通⽤引⽤的实现⽅式。

rem

  • 对于可复制,移动开销低,而且⽆条件复制的参数,按值传递效率基本与按引⽤传递效率⼀致,而 且易于实现,⽣成更少的⽬标代码
  • 通过构造函数拷⻉参数可能⽐通过赋值拷⻉开销⼤的多 按值传递会引起切⽚问题,所说不适合基类类型的参数

item 42 考虑使⽤emplacement代替insertion

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 分析一个push_back:
std::vector<std::string> vs; //std::string的容器
vs.push_back("xyzzy"); //添加字符串字面量
template <class T, //来自C++11标准
class Allocator = allocator<T>>
class vector {
public:

void push_back(const T& x); //插入左值
void push_back(T&& x); //插入右值

};
// 在 vs.push_back("xyzzy") 这个调⽤中,编译器看到参数类型(const char[6])和 push_back 采⽤的 参数类型( std::string 的引⽤)之间不匹配。它们通过从字符串字⾯量创建⼀个 std::string 类型 的临时变量来消除不匹配,然后传递临时变量给 push_back 。换句话说,编译器处理的这个调⽤应该像 这样:
vs.push_back(std::string("xyzzy")); //创建临时std::string,把它传给push_back
// 为了创建 std::string 类型的临时变量,调⽤了 std::string 的构造器,但是这份代码并不仅调⽤了 ⼀次构造器,调⽤了两次,而且还调⽤了析构器。这发⽣在 push_back 运⾏时:
// 1. ⼀个 std::string 的临时对象从字⾯量"xyzzy"被创建。这个对象没有名字,我们可以称为temp,temp通过 std::string 构造器⽣成,因为是临时变量,所以temp是右值。
// 2. temp被传递给 push_back 的右值x重载函数。在 std::vector 的内存中⼀个x的副本被创建。这次 构造器是第⼆次调⽤,在 std::vector 内部重新创建⼀个对象。(将x副本复制到 std::vector 内部的构造器是移动构造器,因为x传⼊的是右值,有关将右值引⽤强制转换为右值的信息,请参 ⻅Item25)。
// 3. 在 push_back 返回之后,temp被销毁,调⽤了⼀次 std::string 的析构器。
// 性能执着者不禁注意到是否存在⼀种⽅法可以获取字符串字⾯量并将其直接 传⼊到步骤2中的 std::string 内部构造,可以避免临时对象temp的创建与销毁。这样的效率最好,性 能执着者也不会有什么意⻅了。所以让我来告诉你如何使得 push_back 达到最⾼的效率。就是不使⽤ push_back ,你需要的是 emplace_back 。
// emplace_back 就是像我们想要的那样做的:直接把传递的参数(⽆论是不是 std::string )直接传递 到 std::vector 内部的构造器。没有临时变量会⽣成:
vs.emplace_back("xyzzy"); // construct std::string inside vs directly from "xyzzy"
// emplace_back 使⽤完美转发,因此只要你没有遇到完美转发的限制(参⻅Item30),就可以传递任何 参数以及组合到 emplace_back 。⽐如,如果你在vs传递⼀个字符和⼀个数量给 std::string 构造器创 建 std::string ,代码如下:
vs.emplace_back(50, 'x'); // insert std::string consisting of 50 'x' characters
// emplace_back 可以⽤于每个⽀持 push_back 的容器。类似的,每个⽀持 push_front 的标准容器⽀持 emplace_front 。每个⽀持 insert (除了 std::forward_list 和 std::array )的标准容器⽀持 emplace。 关联容器提供 emplace_hint 来补充带有“hint”迭代器的插⼊函数, std::forward_list 有 emplace_after 来匹配 insert_after 。
// 使得emplacement函数功能优于insertion函数的原因是它们灵活的接口。insertion函数接受对象来插 ⼊,而emplacement函数接受构造器接受的参数插⼊。这种差异允许emplacement函数避免临时对象 的创建和销毁。
std::string queenOfDisco("Donna Summer");
vs.push_back(queenOfDisco); // copy-construct queenOfDisco
vs.emplace_back(queenOfDisco); // ditto
// 因此,emplacement函数可以完成insertion函数的所有功能。并且有时效率更⾼,⾄上在理论上,不会 更低效。那为什么不在所有场合使⽤它们?但是实际,区别还是有的。在当前标准 库的实现下,有些场景,就像预期的那样,emplacement执⾏性能优于insertion,但是,有些场景反而insertion更快。因 此,⼤致的调⽤建议是:通过benchmakr测试来确定emplacment和insertion哪种更快。
// 还有⼀种启发式的⽅法来帮助你确定是否应该使⽤emplacement。 如果下列条件都能满⾜,emplacement会优于insertion:
// case1: 值是通过构造器添加到容器,而不是直接赋值。例⼦就像本Item刚开始的那样(添加"xyzzy"到 std::string的std::vector 中)。
// case2: 传递的参数类型与容器的初始化类型不同。再次强调,emplacement优于insertion通常基于以下 事实:当传递的参数不是容器保存的类型时,接口不需要创建和销毁临时对象。当将类型为T的对 象添加到container时,没有理由期望emplacement⽐insertion运⾏的更快,因为不需要创建临时 对象来满⾜insertion接口。
// case3: 容器不拒绝重复项作为新值。

// 在决定是否使⽤emplacement函数时,需要注意另外两个问题。
// ⾸先是资源管理。
std::list<std::shared_ptr<Widget>> ptrs;
// 然后你想添加⼀个通过⾃定义deleted释放的 std::shared_ptr (参⻅Item 19)。Item 21说明你应该 使⽤ std::make_shared 来创建 std::shared_ptr ,但是它也承认有时你⽆法做到这⼀点。⽐如当你 要指定⼀个⾃定义deleter时。这时,你必须直接创建⼀个原始指针,然后通过 std::shared_ptr 来管 理。
// 然后你想添加⼀个通过⾃定义deleted释放的 std::shared_ptr
// 如果⾃定义deleter是这个函数
void killWidget(Widget* pWidget);
// 使⽤insertion函数的代码如下:
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
// 也可以像这样
ptrs.push_back({new Widget, killWidget});

// 即使发⽣了异常,没有资源泄露:在调⽤ push_back 中通过 new Widget 创建的 Widget 在 std::shared_ptr 管理下⾃动销毁。⽣命周期良好。
// 考虑使⽤ emplace_back 代替 push_back:
ptrs.emplace_back(new Widget, killWidget);
// 1. 通过`new Widget`创建的原始指针完美转发给`emplace_back`中,*list*节点被分配的位置。如果分配失败,还是抛出内存溢出异常。
// 2. 当异常从`emplace_back`传播,原始指针是仅有的访问堆上`Widget`的途径,但是因为异常而丢失了,那个`Widget`的资源(以及任何它所拥有的资源)发生了泄漏。
// 在这个场景中,⽣命周期不良好,这个失误不能赖 std::shared_ptr 。 std::unique_ptr 使⽤⾃定义deleter也会有同样的问题。根本上讲,像 std::shared_ptr和std::unique_ptr 这样的资源管理类的 有效性取决于资源被⽴即传递给资源管理对象的构造函数。实际上,这就是 std::make_shared和 std::make_unique 这样的函数如此重要的原因。当根据正确的⽅式确保获取资源和连接到资源管理 对象上之间⽆其他操作,添加资源管理类型对象到容器中,emplacement函数不太可能胜过insertion函 数。

// emplacement函数的第⼆个值得注意的⽅⾯是它们与显式构造函数的交互。对于C++11正则表达式的⽀ 持,假设你创建了⼀个正则表达式的容器: push_back会立马报错,但是emplace不会
regexes.emplace_back(nullptr); // add nullptr to container of regexes?
regexes.push_back(nullptr); // error! won't compile

std::regex r = nullptr; // error! won't compile
regexes.push_back(nullptr); // error
// 在上⾯的代码中,我们要求从指针到 std::regex 的隐式转换,但是显式构造的要求拒绝了此类转换。
std::regex r1 = nullptr; // error ! won't compile
std::regex r2(nullptr); // compiles
// 在标准的官⽅术语中,⽤于初始化r1的语法是所谓的复制初始化。相反,⽤于初始化r2的语法是(也被 称为braces)被称为直接初始化。复制初始化不是显式调⽤构造器的,直接初始化是。这就是r2可以编 译的原因。
// 然后回到 push_back和 emplace_back ,更⼀般来说,insertion函数对⽐emplacment函数。emplacement函数使⽤直接初始化,这意味着使⽤显式构造器。
regexes.emplace_back(nullptr); // compiles. Direct init permits use of explicit std::regex ctor taking a pointer
regexes.push_back(nullptr); // error! copy init forbids use of that ctor
// 要汲取的是,当你使⽤emplacement函数时,请特别小⼼确保传递了正确的参数,因为即使是显式构造 函数,编译器可以尝试解释你的代码称为有效的(译者注:这⾥意思是即使你写的代码逻辑上不对,显 式构造器时编译器可能能解释通过即编译成功)


rem

  • 原则上,emplacement函数有时会⽐insertion函数⾼效,并且不会更差
  • 实际上,当执⾏如下操作时,emplacement函数更快
      1. 值被构造到容器中,而不是直接赋值
      1. 传⼊的类型与容器类型不⼀致
      1. 容器不拒绝已经存在的重复值
  • emplacement函数可能执⾏insertion函数拒绝的显⽰构造