16-2 std::vector 和 list 构造函数介绍

在上一课16.1——容器与数组简介中,我们介绍了容器和数组。本课将重点介绍本章后续内容将聚焦的数组类型:std::vector。同时,我们将解决上一课提出的可扩展性挑战中的一个部分。


std::vector 简介

std::vector 是 C++ 标准容器库中实现数组功能的容器类之一。它在 头文件中定义为类模板,通过模板类型参数来定义元素类型。因此,std::vector 声明的 std::vector 元素类型为 int。

实例化 std::vector 对象非常简单:

#include <vector>

int main()
{
	// Value initialization (uses default constructor)
	std::vector<int> empty{}; // vector containing 0 int elements

	return 0;
}

image

变量 empty 被定义为一个元素类型为 int 的 std::vector。由于我们在此使用了值初始化,该向量初始时为空(即不包含任何元素)。

虽然当前看似无用,但这种无元素的向量将在后续课程中再次出现(尤其在 16.11 节——std::vector 与栈的行为中)。


使用值列表初始化 std::vector

由于容器的目的是管理一组相关值,我们通常希望用这些值初始化容器。可通过列表初始化方式,使用特定的初始化值实现。例如:

#include <vector>

int main()
{
	// List construction (uses list constructor)
	std::vector<int> primes{ 2, 3, 5, 7 };          // vector containing 4 int elements with values 2, 3, 5, and 7
	std::vector vowels { 'a', 'e', 'i', 'o', 'u' }; // vector containing 5 char elements with values 'a', 'e', 'i', 'o', and 'u'.  Uses CTAD (C++17) to deduce element type char (preferred).

	return 0;
}

image

对于素数primes向量,我们明确指定需要一个元素类型为 int 的 std::vector。由于提供了 4 个初始化值,该向量将包含 4 个元素,其值分别为 2、3、5 和 7。

对于 vowels,我们未显式指定元素类型。而是利用 C++17 的 CTAD(类模板参数推导)机制,让编译器根据初始化值推导元素类型。由于提供了 5 个初始化值,vowels 将包含 5 个元素,其值分别为 ‘a’、'e'、‘i’、'o' 和 ‘u’。


列表构造函数与初始化列表

让我们更详细地探讨上述机制的工作原理。

第13.8节——结构体聚合初始化中,我们定义初始化列表为用大括号括起的逗号分隔值列表(例如{ 1, 2, 3 })。

容器通常具有名为列表构造函数list constructor的特殊构造函数,允许我们使用初始化列表构造容器实例。列表构造函数执行三项操作:

  • 确保容器拥有足够存储空间容纳所有初始化值(如需扩展)。
  • 将容器长度设置为初始化列表中的元素个数(若需要)。
  • 按顺序将元素初始化为初始化列表中的值。

因此,当我们向容器提供值的初始化列表时,列表构造函数会被调用,容器便会使用该值列表进行构造!

最佳实践:
使用带初始化列表的列表初始化来构造包含这些元素值的容器。

相关内容:
我们在第23.7节——std::initializer_list中讨论了如何为自定义类添加列表构造函数。


使用下标运算符(operator[])访问数组元素

既然我们已经创建了一个元素数组……该如何访问它们呢?

让我们用一个比喻来理解。想象一排并排排列的相同邮箱。为了便于识别,每个邮箱正面都涂有编号。第一个信箱编号为0,第二个编号为1,依此类推。因此若要求你将物品放入编号0的信箱,你便知道这是指第一个信箱。

在C++中,访问数组元素最常见的方式是使用数组名配合下标运算符(operator[])。要选取特定元素,需在下标运算符的方括号内提供整数值来标识目标元素。该整数值称为下标subscript(或非正式地称为索引index)。如同邮箱编号,第一个元素通过索引0访问,第二个通过索引1访问,依此类推。

例如,primes[0] 将返回质数数组中索引为 0 的元素(即第一个元素)。下标运算符返回的是实际元素的引用,而非副本。访问数组元素后,可像操作普通对象一样使用它(例如赋值、输出等)。

由于索引从0而非1开始计数,我们称C++数组为零基zero-based数组。这可能令人困惑,因为我们习惯从1开始计数对象。

关键要点:

索引实际上是相对于数组首元素的距离(偏移量)。
若从数组首元素出发移动0个元素,仍停留在首元素位置。因此索引0即为首元素。
若从数组首元素出发移动1个元素,则到达第二个元素。因此索引1即为第二个元素。
我们在第17.9节——指针运算与下标操作中详细探讨了索引作为相对距离(而非绝对位置)的特性

这也会造成语言上的歧义,因为当我们谈论数组元素1时,可能无法明确指代数组的第一个元素(索引为0)还是第二个元素(索引为1)。通常我们用位置而非索引来描述数组元素(因此“第一个元素”即索引为0的元素)。

以下是一个示例:

#include <iostream>
#include <vector>

int main()
{
    std::vector primes { 2, 3, 5, 7, 11 }; // hold the first 5 prime numbers (as int)

    std::cout << "The first prime number is: " << primes[0] << '\n';
    std::cout << "The second prime number is: " << primes[1] << '\n';
    std::cout << "The sum of the first 5 primes is: " << primes[0] + primes[1] + primes[2] + primes[3] + primes[4] << '\n';

    return 0;
}

这将输出:

image

通过使用数组,我们无需再定义5个不同名称的变量来存储5个质数值。取而代之的是,我们可以定义一个包含5个元素的单一数组(primes),只需改变索引值即可访问不同元素!

关于操作符operator[]及其他访问数组元素的方法,我们将在下一课16.3节——std::vector与无符号长度及下标问题中深入探讨。


下标越界

对数组进行索引时,提供的索引必须选取数组中的有效元素。也就是说,对于长度为N的数组,下标必须是0到N-1(含)之间的值。

operator[]不会进行任何边界检查bounds checking,这意味着它不会验证索引是否在0到N-1(含)的范围内。向[]运算符传递无效下标将导致未定义行为。

避免使用负下标相对容易记住,但更难记住的是:不存在索引为N的元素!数组末尾元素的索引为N-1,因此使用索引N将导致编译器尝试访问超出数组末尾的元素。

提示:
在包含N个元素的数组中,首个元素索引为0,第二个为1,末个元素索引为N-1。不存在索引为N的元素!

使用N作为下标将导致未定义行为(实际尝试访问N+1个元素,该元素不属于数组范围)。

提示:
部分编译器(如Visual Studio)提供运行时索引有效性断言。调试debug模式下若使用无效索引,程序将触发断言异常;发布assert模式中该断言会被编译移除,因此不会造成性能损失。


数组在内存中是连续的

数组的一个基本特征是其元素在内存中总是连续分配的,这意味着所有元素在内存中都是相邻的(之间没有间隔)。

例如:

#include <iostream>
#include <vector>

int main()
{
    std::vector primes { 2, 3, 5, 7, 11 }; // hold the first 5 prime numbers (as int)

    std::cout << "An int is " << sizeof(int) << " bytes\n";
    std::cout << &(primes[0]) << '\n';
    std::cout << &(primes[1]) << '\n';
    std::cout << &(primes[2]) << '\n';

    return 0;
}

在作者的机器上,运行上述程序会产生以下结果:

image

你会注意到这些 int 元素的内存地址相差 4 字节,这与作者机器上 int 的大小相同。

这意味着数组不存在按元素计的开销。它还允许编译器快速计算数组中任意元素的地址。

相关内容:
我们在第 17.9 课——指针运算与下标运算中将探讨下标运算背后的数学原理。

数组是少数支持随机访问random access的容器类型之一,这意味着容器中的任意元素均可直接访问(区别于顺序访问,后者要求按特定顺序访问元素)。数组元素的随机访问通常效率很高,使得数组使用起来非常便捷。这也是数组常被优先选择于其他容器的主要原因。


构造特定长度的 std::vector

假设我们需要用户输入10个值并存储到 std::vector 中。此时,在向 std::vector 添加任何值之前,我们需要先创建一个长度为10的 std::vector。如何实现?

我们可以创建一个 std::vector,并使用包含 10 个占位符值的初始化列表进行初始化:

std::vector<int> data { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; // vector containing 10 int values

但这种做法存在诸多弊端:不仅需要大量输入,难以直观判断初始化项的数量,且当后续需要调整值的数量时,更新操作也变得困难。

所幸,std::vector 提供了显式构造函数(explicit std::vector(std::size_t)),该构造函数仅需一个 std::size_t 参数来定义要构造的 std::vector 长度:

std::vector<int> data( 10 ); // vector containing 10 int elements, value-initialized to 0

创建的每个元素都采用值初始化,对于 int 类型执行零初始化(对于类类型则调用默认构造函数)。

但使用此构造函数时存在一个不易察觉的细节:必须通过直接初始化方式调用它。


非空初始化列表优先使用列表构造器

要理解为何前一个构造器必须使用直接初始化调用,请考虑以下定义:

std::vector<int> data{ 10 }; // what does this do?

存在两个不同的构造函数与该初始化匹配:

  • { 10 } 可被解释为初始化列表,通过列表构造函数构造出长度为1、值为10的向量。
  • { 10 } 也可解释为单个大括号初始化值,匹配 std::vector(std::size_t) 构造函数,构造长度为 10 的向量,其元素均初始化为 0。

通常当类类型定义匹配多个构造函数时,匹配会被视为歧义并导致编译错误。但C++对此情形设有特殊规则:当初始化列表非空时,列表构造函数将优先于其他匹配构造函数。若无此规则,列表构造函数将与任何接受单一类型参数的构造函数产生歧义匹配。

由于{ 10 }可被解释为初始化列表,且std::vector具备列表构造函数,故在此情况下列表构造函数具有优先权。

关键要点:
使用初始化列表构造类类型对象时:

  • 若初始化列表为空,则默认构造函数优先于列表构造函数。
  • 若初始化列表不为空,则匹配的列表构造函数优先于其他匹配的构造函数。
// Copy init
std::vector<int> v1 = 10;     // 10 not an initializer list, copy init won't match explicit constructor: compilation error

// Direct init
std::vector<int> v2(10);      // 10 not an initializer list, matches explicit single-argument constructor

// List init
std::vector<int> v3{ 10 };    // { 10 } interpreted as initializer list, matches list constructor

// Copy list init
std::vector<int> v4 = { 10 }; // { 10 } interpreted as initializer list, matches list constructor
std::vector<int> v5({ 10 });  // { 10 } interpreted as initializer list, matches list constructor

        // Default init
        std::vector<int> v6 {};       // {} is empty initializer list, matches default constructor
        std::vector<int> v7 = {};     // {} is empty initializer list, matches default constructor

在情况 v1 中,初始化值 10 不是初始化列表,因此列表构造函数不匹配。单参数构造函数 explicit std::vector(std::size_t) 同样不匹配,因为复制初始化无法匹配显式构造函数。由于没有构造函数匹配,这将导致编译错误。

在情况 v2 中,初始化值 10 不是初始化列表,因此列表构造函数不匹配。单参数构造函数 explicit std::vector(std::size_t) 匹配,故选择该构造函数。

在 v3 情况(列表初始化)中,{ 10 } 可匹配列表构造函数或显式构造函数 std::vector(std::size_t)。列表构造函数优先于其他匹配构造函数,因此被选中。

在情况 v4(复制列表初始化)中,{ 10 } 可匹配列表构造函数(该构造函数为非显式构造函数,因此可用于复制初始化)。最终选择列表构造函数。

v5 案例出人意料地是复制列表初始化的替代语法(非直接初始化),其行为与 v4 相同。

这是 C++ 初始化机制的缺陷之一:{ 10 } 会匹配列表构造函数(若存在),或匹配单参数构造函数(若列表构造函数不存在)。这意味着实际行为取决于列表构造函数是否存在!通常可假设容器都具备列表构造函数。

警告:
若类未提供列表构造函数,但后续添加了该构造函数,这将改变所有使用非空初始化列表初始化的对象所调用的构造函数。

v6和v7均使用空初始化列表初始化。此情况下,默认构造函数具有优先权。

总结而言,列表初始化器通常用于通过元素值列表初始化容器,应遵循此设计初衷。这本就是多数场景下的预期行为。因此,当 10 作为元素值时,{ 10 } 才是正确写法;若 10 作为容器非列表构造函数的参数,则应使用直接初始化。

最佳实践:
当使用非元素值初始化器构造容器(或任何具有列表构造函数的类型)时,应采用直接初始化。

提示:
当 std::vector 是类类型的成员时,如何提供将 std::vector 的长度设置为某个初始值的默认初始值设定项并不明显:

#include <vector>

struct Foo
{
    std::vector<int> v1(8); // compile error: direct initialization not allowed for member default initializers
};

这行代码无法正常工作,因为成员默认初始化器不允许使用直接(括号)初始化。
为类型的成员提供默认初始化器时:

  • 必须使用复制初始化或列表初始化(直接或复制)。
  • 不允许使用CTAD(因此必须显式指定元素类型)。

正确答案如下:

struct Foo
{
    std::vector<int> v{ std::vector<int>(8) }; // ok
};

这会创建一个容量为8的std::vector,然后将其用作v的初始化器。


const 和 constexpr std::vector

std::vector 类型的对象可以被声明为 const:

#include <vector>

int main()
{
    const std::vector<int> prime { 2, 3, 5, 7, 11 }; // prime and its elements cannot be modified

    return 0;
}

const std::vector 必须初始化,且初始化后不可修改。此类向量的元素将被视为 const 类型。

std::vector 的元素类型不得定义为 const(例如 std::vector 是不被允许的)。

关键洞见:
根据 Howard Hinnant 在此处的评论,标准库容器的设计初衷并不包含 const 元素。
容器的常量性源于容器本身的常量化,而非其元素。

std::vector 的最大缺陷之一是无法实现 constexpr。若需 constexpr 数组,请使用 std::array。

相关内容:
我们在第 17.1 课std::array 简介 中详细讲解了 std::array。


为何称为向量?

人们在日常对话中使用“向量”一词时,通常指代具有大小和方向的几何向量。那么,既然std::vector并非几何向量,其命名依据何在?

亚历山大·斯捷潘诺夫在《从数学到泛型编程》一书中写道: “STL中的vector名称源自早期编程语言Scheme和Common Lisp。遗憾的是,这与该术语在数学领域更古老的含义不符……这种数据结构本应称为array。可悲的是,一旦违反这些原则犯下错误,其后果可能长期存在。”

因此,std::vector本质上是命名不当,但如今已为时过晚无法更改。


测验时间

问题 #1

使用CTAD定义一个std::vector,并用前5个正平方数(1, 4, 9, 16, 25)初始化它。

显示解决方案

std::vector squares{ 1, 4, 9, 16, 25 };

image


问题 #2

以下两种定义的行为差异是什么?

std::vector<int> v1 { 5 };
std::vector<int> v2 ( 5 );

image

显示解决方案

v1 调用列表构造函数,定义一个包含值 5 的 1 元素向量。
v2 调用非列表构造函数,定义一个包含 5 个元素的向量,其元素采用值初始化。

问题 #3

定义一个 std::vector(使用显式模板类型参数),用于存储一年中每天的最高温度(精确到十分之一度,假设一年有 365 天)。

显示解决方案

std::vector<double> temperature (365); // create a vector to hold 365 double values

问题 #4

使用 std::vector 编写程序,要求用户输入 3 个整数值。输出这些值的和与积。

输出应符合以下格式:

Enter 3 integers: 3 4 5
The sum is: 12
The product is: 60

image

显示解决方案

#include <iostream>
#include <vector>

int main()
{
	std::vector<int> arr(3); // create a vector of length 3

	std::cout << "Enter 3 integers: ";
	std::cin >> arr[0] >> arr[1] >> arr[2];

	std::cout << "The sum is: " << arr[0] + arr[1] + arr[2] << '\n';
	std::cout << "The product is: " << arr[0] * arr[1] * arr[2] << '\n';

	return 0;
}
posted @ 2026-01-04 19:59  游翔  阅读(14)  评论(0)    收藏  举报