这一章在干嘛?

前面各章你已经用过 string,这一章把它彻底讲透:构造、输入、查找、容量。接着介绍三个”会自己删内存”的智能指针,解决 new/delete 配对的老大难问题。本章的重头戏是 STL(标准模板库)——C++ 里最常用的一套”容器 + 迭代器 + 算法”组合拳,它体现的泛型编程思想将贯穿你后续的 C++ 生涯。最后补上 C++11 的 initializer_list

16.1 string 类详解

16.2 智能指针模板类

16.3 STL 与 vector

16.4 迭代器与泛型编程

16.5 容器大点兵

16.6 函数对象

16.7 STL 算法

16.8 vector、valarray 与 array

16.9 initializer_list 模板

16.1 string 类详解

string 其实是模板 basic_string<char> 的 typedef,头文件是 <string>(注意不是 C 的 <cstring>)。它重载了 =++=[]<<==< 等一大票运算符,所以用起来像普通变量一样自然。

七种构造方式,一次看全:

// str1.cpp -- introducing the string class
#include <iostream>
#include <string>
 
int main()
{
    using namespace std;
    string one("Lottery Winner!");     // ctor #1: C 风格字符串
    cout << one << endl;               // 重载的 <<
    string two(20, '$');               // ctor #2: 20 个 '$'
    cout << two << endl;
    string three(one);                 // ctor #3: 复制构造
    one += " Oops!";                   // 重载的 +=
    two = "Sorry! That was ";          // 重载的 =
    three[0] = 'P';                    // 重载的 [],可读可写
    string four;                       // ctor #4: 默认空串
    four = two + three;                // 重载的 + 和 =
    char alls[] = "All's well that ends well";
    string five(alls, 20);             // ctor #5: 取前 20 个字符
    string six(alls + 6, alls + 10);   // ctor #6: 指针范围 [begin, end)
    cout << six << ", ";
    string seven(&five[6], &five[10]); // ctor #6 再来一次
    string eight(four, 7, 16);         // ctor #7: 从位置 7 取 16 个字符
    cout << eight << " in motion!" << endl;
    return 0;
}

范围构造 [begin, end)string six(alls+6, alls+10) 中,begin 包含、end 不包含——end 指向”最后一个要用元素的后一个位置”。这个数学式的半开区间约定,之后会贯穿整个 STL,务必现在就刻进脑子。

▲ 图 16.1 用范围 [begin, end) 构造字符串

注意:string seven(five + 6, five + 10) 是错的——对象名不像数组名,不是地址。要写 &five[6]。C++11 又补了两个构造函数:移动构造 string(string && str)(第 18 章详解)和接受 initializer_list<char> 的版本(string comp_lang {'L','i','s','p'};)。

string 的输入:C 风格字符串有 cin >> infocin.getline(info, 100) 两种;string 对象则是 cin >> stuff(读一个词)和 getline(cin, stuff)(读一行)。核心区别在于:string 版会自动调整大小,永远不会截断你的输入,所以 getline 不需要第二个长度参数。这也是为什么 C 版的 getline 是 istream 的方法(cin.getline(...)),而 string 版是独立函数(getline(cin, stuff))。

string 版 getline 停止读取的三种情形:读到文件尾(设 eofbit);遇到分隔符(默认 \n,读走但不存);读到 string::npos 上限(设 failbit)。

查找方法find() 有四个重载版本,都返回 string::npos(无符号整型最大值)表示”没找到”。家族成员还有 rfind()(从后往前找)、find_first_of()(找参数中任意字符首次出现)、find_last_of()find_first_not_of()find_last_not_of()

一个实战味十足的游戏程序:猜单词(Hangman)。

// hangman.cpp -- some string methods
#include <iostream>
#include <string>
#include <cstdlib>
#include <ctime>
#include <cctype>
using std::string;
const int NUM = 26;
const string wordlist[NUM] = { "apiary", "beetle", "cereal",
    "danger", "ensign", "florid", "garage", "health", "insult",
    "jackal", "keeper", "loaner", "manage", "nonce", "onset",
    "plaid", "quilt", "remote", "stolid", "train", "useful",
    "valid", "whence", "xenon", "yearn", "zippy" };
int main()
{
    using std::cout;
    using std::cin;
    using std::tolower;
    using std::endl;
    std::srand(std::time(0));
    char play;
    cout << "Will you play a word game? <y/n>";
    cin >> play;
    play = tolower(play);
    while (play == 'y')
    {
        string target = wordlist[std::rand() % NUM];
        int length = target.length();
        string attempt(length, '-');   // 与谜底等长的连字符串
        string badchars;
        int guesses = 6;
        cout << "Guess my secret word. It has " << length
             << " letters.\nYour word: " << attempt << endl;
        while (guesses > 0 && attempt != target)
        {
            char letter;
            cout << "Guess a letter: ";
            cin >> letter;
            if (badchars.find(letter) != string::npos
                || attempt.find(letter) != string::npos)
            {
                cout << "You already guessed that. Try again.\n";
                continue;
            }
            int loc = target.find(letter);
            if (loc == string::npos)
            {
                cout << "Oh, bad guess!\n";
                --guesses;
                badchars += letter;    // += 可以追加单个字符
            }
            else
            {
                cout << "Good guess!\n";
                attempt[loc] = letter;
                loc = target.find(letter, loc + 1); // 从下一处继续找
                while (loc != string::npos)
                {
                    attempt[loc] = letter;
                    loc = target.find(letter, loc + 1);
                }
            }
            cout << "Your word: " << attempt << endl;
        }
        if (guesses > 0)
            cout << "That's right!\n";
        else
            cout << "Sorry, the word is " << target << ".\n";
        cout << "Will you play another? <y/n> ";
        cin >> play;
        play = tolower(play);
    }
    cout << "Bye\n";
    return 0;
}

容量机制:每次 += 都重新分配内存太浪费,所以实现通常预留超额空间。capacity() 返回当前分配块大小,reserve() 请求最小容量。最后,需要 C 风格字符串时(如 fout.open(...)),用 filename.c_str() 转换。

常见坑:string::npos 不是 -1

find() 找不到时返回 string::npos,它定义成无符号整型的最大值。如果你写 if (str.find('x') < 0) 永远不成立,因为无符号数不会小于 0。正确写法是 != string::npos。另外 string five(alls, 40) 这种”长度超过原串”的构造会把后面的内存垃圾也拷进来。

通关标准:

能不查资料写出七种 string 构造方式;能解释 [begin, end) 半开区间的含义;知道 c_str() 的用途;理解 capacity()size() 的区别。

16.2 智能指针模板类

先看一段内存泄漏的反面教材:

void remodel(std::string & str)
{
    std::string * ps = new std::string(str);
    ...
    if (weird_thing())
        throw exception();   // 异常一抛,下面的 delete 永远执行不到
    str = *ps;
    delete ps;
    return;
}

“记得 delete”从来不是好方案。智能指针的思路:让指针本身是个类对象,对象消亡时析构函数自动 delete 所指向的内存。头文件 <memory>,三个模板:

std::auto_ptr<std::string> ps (new std::string(str));    // C++98,已弃用
std::unique_ptr<std::string> pdu (new std::string(str)); // C++11,独占
std::shared_ptr<std::string> pss (new std::string(str)); // C++11,共享

▲ 图 16.2 普通指针与 auto_ptr 的行为对比:auto_ptr 过期时自动释放内存

三者用法几乎一样,区别在赋值时的策略。两个指针指向同一块内存、析构时 delete 两次,是灾难,所以:

  • auto_ptr / unique_ptr 采用”所有权”模型:赋值即转移所有权。区别是 auto_ptr 转移后原指针悄悄变成空指针(用起来就崩),而 unique_ptr 要求源对象必须是临时右值才允许赋值,否则编译期报错
  • shared_ptr 采用引用计数:赋值计数 +1,指针过期计数 -1,减到 0 才真正 delete。

看一个 auto_ptr 的”翻车现场”:

// fowl.cpp -- auto_ptr a poor choice(节选)
auto_ptr<string> films[5] = {
    auto_ptr<string> (new string("Fowl Balls")),
    auto_ptr<string> (new string("Duck Walks")),
    auto_ptr<string> (new string("Chicken Runs")),
    auto_ptr<string> (new string("Turkey Errors")),
    auto_ptr<string> (new string("Goose Eggs"))
};
auto_ptr<string> pwin;
pwin = films[2]; // films[2] 交出所有权,变成空指针
 
for (int i = 0; i < 5; i++)
    cout << *films[i] << endl;   // 打到 films[2] 时直接段错误!

换成 shared_ptr 程序正常(引用计数 1→2→1→0);换成 unique_ptr 则在 pwin = films[2] 这行编译不过——把运行期崩溃提前到编译期,这正是它优于 auto_ptr 的地方。若确实想转移,用 std::move() 显式表态:

ps2 = move(ps1); // 显式移交,ps1 之后的值未定义,但这是你自己选的

选型建议:多处共用同一对象选 shared_ptr;独占式管理选 unique_ptr(尤其适合做函数返回值,把所有权交给调用方);auto_ptr 别再用了。另外三者都只能管 new 出来的内存——栈变量、new [] 分配的数组都不能交给它们(unique_ptr 有 unique_ptr<double[]> 数组特化版除外)。

常见坑:把栈地址交给智能指针

string vacation("hello"); shared_ptr<string> pvac(&vacation); // 大错!——pvac 过期时会对栈内存执行 delete,直接未定义行为。智能指针只认 new 返回的堆地址。

16.3 STL 与 vector

STL 是一套”容器 + 迭代器 + 算法 + 函数对象”的模板集合,由 Alex Stepanov 等人在 HP 实验室开发,1994 年被纳入 C++ 标准。它代表的不是面向对象,而是泛型编程(generic programming)。

vector 是最常用的容器——可动态伸缩的数组:

// vect1.cpp -- introducing the vector template(节选)
#include <vector>
vector<int> ratings(NUM);     // 5 个 int
vector<string> titles(NUM);   // 5 个 string
for (int i = 0; i < NUM; i++)
{
    cout << "Enter title #" << i + 1 << ": ";
    getline(cin, titles[i]);
    cin >> ratings[i];
    cin.get();
}

所有容器共有的基本方法:size()(元素个数)、swap()(交换两个容器)、begin()(指向首元素的迭代器)、end()(指向超尾的迭代器)。

▲ 图 16.3 STL 的范围概念:[begin, end) 覆盖整个容器

迭代器可以理解为”泛化的指针”:

vector<double>::iterator pd;   // 声明迭代器
pd = scores.begin();           // 指向首元素
*pd = 22.3;                    // 解引用赋值
++pd;                          // 前移
for (pd = scores.begin(); pd != scores.end(); pd++)
    cout << *pd << endl;       // 遍历整个容器

C++11 可以偷懒:auto pd = scores.begin();

vector 专属的常用方法:push_back() 尾部追加(自动扩容)、erase(begin, end) 删除区间、insert(pos, first, last) 在某位置前插入区间。超尾的价值在这里体现了——insert(old_v.end(), ...) 就是”追加到末尾”。

STL 的算法大多定义成非成员函数而非容器方法。8 个容器 × 10 个操作,若做成成员函数要写 80 个;做成通用函数只需 10 个,而且你自己的新容器也能直接享用。当然某些容器会额外提供特化版成员函数(如 list 的 sort()),因为类内部知道数据布局,可以做得更快。

常见坑:copy() 不扩容

copy(casts, casts+10, dice.begin())覆写,要求目标容器已有足够空间。往空 vector 里 copy 会直接内存越界。想边拷边长,得用插入迭代器(见 16.4)或 push_back

16.4 迭代器与泛型编程

为什么需要迭代器?设想给”数组”和”链表”各写一个 find 函数:数组用下标走,链表用 p_next 指针走,算法逻辑完全不同。但抽象一层看,两者都是”逐个比较元素直到命中”。迭代器就是把”在容器中移动”这件事抽象成统一接口:能解引用 *p、能比较 p == q、能前进 ++p。普通指针天然满足这些要求,所以指针就是迭代器的一种(模型)。

每个容器类都定义了自己的 iterator 类型(类内 typedef),并提供 begin()/end()。于是同一段遍历代码,把 vector<double>::iterator 换成 list<double>::iterator 就能原样用于链表:

for (auto pr = scores.begin(); pr != scores.end(); pr++)
    cout << *pr << endl;   // vector 和 list 通用

五种迭代器(能力递增的层级):

类型能力典型算法
输入迭代器单遍、只读、只进find()
输出迭代器单遍、只写、只进copy() 的目标
正向迭代器多遍、固定顺序、读写replace()
双向迭代器正向 + 反向 --list 内部算法
随机访问迭代器可跳跃 +n、下标 a[n]、比较大小sort()

关键点:算法按”最低需求”声明参数。find() 只要输入迭代器,所以任何容器都能用;sort() 要随机访问迭代器,list 就用不了通用 sort(),只能用类内的 list::sort() 成员。注意这些”概念”不是 C++ 类型,编译器不会替你检查,文档用 InputIterator、RandomAccessIterator 这类参数名提醒你。

常用的预定义迭代器(头文件 <iterator>):

// copyit.cpp -- copy() 与迭代器(节选)
int casts[10] = {6, 7, 2, 9, 4, 11, 8, 7, 10, 5};
vector<int> dice(10);
copy(casts, casts + 10, dice.begin());       // 数组 → vector
 
ostream_iterator<int, char> out_iter(cout, " ");
copy(dice.begin(), dice.end(), out_iter);    // 容器 → 屏幕
copy(dice.rbegin(), dice.rend(), out_iter);  // 逆序输出,一行搞定
 
// 插入迭代器:把"覆写"变成"插入"
copy(s2, s2 + 2, back_insert_iterator<vector<string>>(words)); // 尾插
copy(s3, s3 + 2, insert_iterator<vector<string>>(words, words.begin())); // 头插

back_insert_iterator 要求容器能在尾部常数时间插入(vector 可以,list 也可以);front_insert_iterator 要求头部常数时间插入(vector 不行,deque/list 行);insert_iterator 无限制。它们能通过容器的 push_back() 等方法自动扩容,绕开了 copy() 不扩容的限制。

16.5 容器大点兵

序列容器(元素有确定线性顺序):vector、deque、list、forward_list(C++11)、queue、priority_queue、stack。前五种要求支持 insert()erase() 等序列操作。

  • vector:可变长数组,随机访问快;尾插尾删常数时间,头插中间插是线性时间。默认之选。
  • deque:双端队列,读作”deck”。头尾插入删除都是常数时间,也支持随机访问。两端操作多时选它。
  • list:双向链表。任意位置插入删除都是常数时间,但不支持随机访问和下标。迭代器在插入删除后依然指向原元素(vector 插入元素后迭代器可能失效)。

▲ 图 16.4 push_front() 与 push_back():list 和 deque 支持头部常数时间插入,vector 不支持

list 的专属工具箱:

// list.cpp -- using a list(核心片段)
list<int> one(5, 2);                          // 5 个 2
two.insert(two.begin(), stuff, stuff + 5);    // 区间插入
three.remove(2);                              // 删除所有 2
three.splice(three.begin(), one);             // 剪切拼接,one 变空
three.unique();                               // 折叠相邻重复值
three.sort();                                 // list 专用排序
three.merge(two);                             // 合并两个有序链表

注意 unique() 只折叠相邻的重复值,所以”先 sort 再 unique”才能全量去重。splice()insert() 不同:它是搬移而非复制,源链表被搬空。

  • queue:适配器(adapter),默认基于 deque。只许队尾进、队头出,提供 push()/pop()/front()/back()/size()/empty()pop() 只删不取,取值要先 front()
  • priority_queue:最大值永远排队首(默认用 < 比较),可用 priority_queue<int, vector<int>, greater<int>> 改成最小值优先。
  • stack:适配器,默认基于 vector。push()/pop()/top() 三板斧,后进先出。
  • array(C++11):定长,不是完全的 STL 容器(不能 resize),但支持 begin()/end(),可用 STL 算法。

关联容器:把值和键(key)绑在一起,用键快速查找,底层通常是树,插入时自动排序,不允许你指定插入位置。

  • set:键就是值,键唯一,自动排序。
  • multiset:一个键可以有多个值。
  • map:键值对,键唯一。
  • multimap:一键多值。
// setops.cpp -- some set operations(节选)
string s1[N] = {"buffoon", "thinkers", "for", "heavy", "can", "for"};
set<string> A(s1, s1 + N);        // "for" 只留一份,且已排序
set_union(A.begin(), A.end(), B.begin(), B.end(), out);          // 并集
set_intersection(A.begin(), A.end(), B.begin(), B.end(), out);   // 交集
set_difference(A.begin(), A.end(), B.begin(), B.end(), out);     // 差集
set<string> C;
set_union(A.begin(), A.end(), B.begin(), B.end(),
          insert_iterator<set<string>>(C, C.begin())); // 写回集合 C
C.lower_bound("ghost");  // 第一个 >= "ghost" 的元素
C.upper_bound("spook");  // 第一个 >  "spook" 的元素

为什么写回 C 要用 insert_iterator 而不是 C.begin()?两个原因:集合的键是常量,begin() 返回的是常量迭代器不能当输出迭代器;且 C 是空的,copy 型算法要求目标有足够空间。

multimap 用 pair<const Key, T> 存元素,用 equal_range() 取同键的所有值:

// multmap.cpp -- use a multimap(节选)
typedef std::pair<const KeyType, std::string> Pair;
typedef std::multimap<KeyType, std::string> MapCode;
MapCode codes;
codes.insert(Pair(718, "Brooklyn"));
codes.insert(Pair(718, "Staten Island"));   // 同一个键 718
cout << codes.count(718);                   // 2
auto range = codes.equal_range(718);        // 返回一对迭代器
for (auto it = range.first; it != range.second; ++it)
    cout << (*it).second << endl;

C++11 还新增了基于哈希表的无序关联容器:unordered_setunordered_multisetunordered_mapunordered_multimap——增删更快、查找平均常数时间。

16.6 函数对象

函数对象(functor)泛指”能像函数一样加 () 调用的东西”:函数名、函数指针、重载了 operator()() 的类对象。类的版本有个杀手锏——可以用成员变量携带额外信息

// functor.cpp -- using a functor(核心)
template<class T>
class TooBig
{
private:
    T cutoff;
public:
    TooBig(const T & t) : cutoff(t) {}
    bool operator()(const T & v) { return v > cutoff; }
};
 
TooBig<int> f100(100);
yadayada.remove_if(f100);              // 删除所有 > 100 的元素
etcetera.remove_if(TooBig<int>(200));  // 匿名对象,阈值 200

普通函数 bool tooBig(int n) 只能有一个参数,想换个阈值就得再写一个函数;functor 用构造函数存阈值,一份代码通吃。STL 概念上把 functor 分为:generator(无参)、一元函数(一参)、二元函数(两参),返回 bool 的一元/二元函数分别叫 predicate(谓词)和 binary predicate。

STL 预定义了一批 functor(头文件 <functional>),与运算符一一对应:plusminusmultipliesdividesmodulusnegateequal_tonot_equal_togreaterlessgreater_equalless_equallogical_andlogical_orlogical_not。典型用法是给 transform() 当参数:

// funadap.cpp -- using function adapters(节选)
vector<double> sum(LIM);
transform(gr8.begin(), gr8.end(), m8.begin(), sum.begin(),
          plus<double>());                 // 逐元素相加
vector<double> prod(LIM);
transform(gr8.begin(), gr8.end(), prod.begin(),
          bind1st(multiplies<double>(), 2.5));  // 每个元素 × 2.5

bind1st/bind2nd 是函数适配器:把二元函数的一个参数绑定成常量,降级成一元函数。C++11 里它们已被更灵活的 bind 和 lambda 取代,但老代码里常见。C++11 的 lambda 是更现代的替代方案(第 18 章详述)。

16.7 STL 算法

STL 算法分四组:非修改式(find、for_each——只看不改)、修改式(transform、random_shuffle、copy)、排序及相关(sort、各种 set 操作)、通用数值运算(在 <numeric> 里,其余在 <algorithm>)。

两个通用命名约定:

  • _copy 后缀:复制版本,结果写到新位置。如 replace() 原地替换,replace_copy() 结果拷到别处并返回”最后拷贝位置之后”的迭代器。
  • _if 后缀:接受一个谓词决定是否执行。如 replace_if()replace_copy_if()

string 虽不属于 STL,但设计了 begin()/end() 等接口,可以直接吃 STL 算法:

// strgstl.cpp -- applying the STL to a string(核心)
string letters;
cin >> letters;
sort(letters.begin(), letters.end());          // 先排成最小字典序
do {
    cout << letters << endl;
} while (next_permutation(letters.begin(), letters.end()));

成员函数 vs 通用算法,该选谁?通常成员函数更好:针对容器特化过、能自动调整容器大小。最经典的对比例子:

// listrmv.cpp -- 核心对比
la.remove(4);              // 方法版:真删,list 从 10 个变 6 个
 
list<int>::iterator last;
last = remove(lb.begin(), lb.end(), 4);  // 算法版:不能改大小!
// lb 仍是 10 个元素:5 2 2 3 8 1 4 8 1 4(后 4 个是残留垃圾)
lb.erase(last, lb.end());  // 需要手动 erase 掉残留段

通用算法的优势在于通用:数组、string、以及跨容器类型混合操作(vector 数据存进 set)都行。

来一个 STL 组件协作的完整示例——统计输入单词的出现频率:

// usealgo.cpp -- using several STL elements(核心)
vector<string> words;                       // 按输入顺序记录
while (cin >> input && input != "quit")
    words.push_back(input);
 
set<string> wordset;                        // 自动排序 + 自动去重
transform(words.begin(), words.end(),
          insert_iterator<set<string>>(wordset, wordset.begin()), ToLower);
 
map<string, int> wordmap;                   // 单词 → 次数
for (auto si = wordset.begin(); si != wordset.end(); si++)
    wordmap[*si] = count(words.begin(), words.end(), *si);

vector 管顺序、set 管排序去重、map 管键值统计——三个容器各司其职,一行算法干一件事。用 STL 的正确姿势是能不写的代码就不写

16.8 vector、valarray 与 array

为什么 C++ 有三个数组模板?因为出身不同、使命不同:

  • vector:STL 容器家族成员,强在排序、插入、搜索、容器间搬运。
  • valarray:面向数值计算,不是 STL。重载了全部算术运算符,写公式最爽。
  • array:定长数组的替身,紧凑高效且接口更安全,支持 begin()/end()。

对比一目了然:

// vector/array 版:逐元素相加要靠算法
transform(ved1.begin(), ved1.end(), ved2.begin(), ved3.begin(), plus<double>());
// valarray 版:直接写算式
vad3 = vad1 + vad2;
vad3 = 2.5 * vad3;          // 整体缩放
vad3 = log(vad1);           // 数学函数整体作用
vad3 = 10.0 * ((vad1 + vad2) / 2.0 + vad1 * cos(vad2));  // 多步算式一行写完

但 valarray 没有 begin()/end(),通用 sort() 用不了;C++11 补了 sort(begin(vad), end(vad)) 的模板函数版本救场。valarray 还有个看家本领 slice 切片——用 slice(start, number, stride) 当下标,把一维数组当二维矩阵用:

// vslice.cpp -- valarray slices(节选)
vint valint(12);                 // 当作 4 行 × 3 列
vint vcol(valint[slice(1, 4, 3)]);        // 取第 2 列:下标 1,4,7,10
valint[slice(2, 4, 3)] = 10;              // 第 3 列整体赋值为 10
valint[slice(0,4,3)] = vint(valint[slice(1,4,3)])
                     + vint(valint[slice(2,4,3)]); // 第 1 列 = 第 2 列 + 第 3 列

16.9 initializer_list 模板

C++11 让花括号列表初始化几乎无处不在:

std::vector<double> payments {45.99, 39.23, 19.95, 89.01};

能这么做是因为容器类有了接受 initializer_list<T> 的构造函数。规则:只要类有 initializer_list 构造函数,{} 语法就优先匹配它——

vector<int> a1(10);    // 10 个未初始化元素(圆括号版)
vector<int> a2{10};    // 只有 1 个元素,值为 10(花括号版走 initializer_list)
vector<int> a3{4,6,1}; // 3 个元素

列表内元素会隐式转换到元素类型({45.99, 39.23, 19, 89} 里两个 int 转 double),但禁止收窄转换vector<int> values = {10, 8, 5.5}; 是编译错误,5.5 转 int 会丢信息。

自己也能用:头文件 <initializer_list>,有 begin()/end()/size(),元素是 const 不可改:

// ilist.cpp -- use initializer_list
double sum(std::initializer_list<double> il);
double average(const std::initializer_list<double> & ril);
 
double sum(std::initializer_list<double> il)
{
    double tot = 0;
    for (auto p = il.begin(); p != il.end(); p++)
        tot += *p;
    return tot;
}
// 调用:sum({2,3,4}),也可传入具名的 initializer_list 变量

定长类的构造函数(如 Position(int x, int y, int z)不要提供 initializer_list 版本——元素个数固定的类用花括号反而会走错构造函数。

常见坑: vector<int> v{10} 不是"10 个元素"

花括号触发的 initializer_list 构造让你得到1 个值为 10 的元素;想要 10 个元素请写 vector<int> v(10)。同理 auto 声明加大括号会推导出 initializer_list 类型,别踩。