C++ 第六章 类型与声明 - 6.3 声明

6.3 声明

在C++程序中要想使用某个名字(标识符),必须先对其进行声明。换句话说,我们必须指定它的类型以便编译器知道这个名字对应的是何种实体。例如:

char ch;
string s;
auto count = 1;
const double ps{3.1415926535897};
extern int error_number;

const char* name = “Njal”;
const char* season[] = {“spring”, “summer”, “fall”, “winter”};
vector<string> people{name, “Skarphendin”, “Gunnar”};
struct Date{int d, m, y;};
int day(Date* p){return p->d;}
double sqrt(double);
template<class T> T abs(T a){return a<0 ? -a : a;}

constexpr int fac(int n){return(n<2)?1:n*fac(n-1);}	//可能的编译时求值(见2.2.3节)
constexpr double zz{ii*fac(7);}						//编译时初始化

using Cmplx = std::complex<double>;					//类型别名(见3.4.5节和6.5节)
struct User;										//类型名字
enum class Beer{Carlsberg, Tuborg, Thor};
namespace NS{int a;}

从上面这些例子可以看出,声明语句的作用不止把类型和名字关联起来这么简单。大多数声明(declaration)同时也是定义(definition)。我们可以把定义看成是一种特殊的声明,它提供了在程序中使用该实体所需的一切信息。尤其是当实体需要内存空间来存储某些信息时,定义语句把所需的内存预留了出来。还有一种观点认为声明是接口的一部分,而定义属于实现的范畴。在这种视角下,我们尽量用声明语句组成程序的接口。其中,同一个声明可以在不同文件中重复出现(见15.2.2节)。负责申请内存空间的定义语句不属于接口。

假定这些声明语句位于全局作用域中(见6.3.4节),则:

char ch;							//为一个char类型的变量分配内存空间并赋初值0
auto count = 1;						//为一个int类型的变量分配内存空间并赋初值1
const char* name = “Njal”;			//为一个指向char的指针分配内存空间
									//为字符串字面值常量“Njal”分配内存空间
									//用字符串字面值常量的地址初始化指针

struct Date{int d, m, y;};			//Date是一个struct,它包含3个成员
int day(Date* p){return p->d;}		//day是一个函数,它执行某些既定的代码

using Point = std::complex<short>;	//Point是类型std::complex<short>的别名

在上面这些声明语句中,只有3个不是定义:

double sqrt(double);			//函数声明
extern int error_number;		//变量声明
struct User;					//类型名字声明

也就是说,要想使用它们对应的实体,必须先在其他某处进行定义。例如:

double sqrt(double d){/*...*/}
int error_number = 1;
struct User{/*...*/};

在C++程序中每个名字可以对应多个声明语句,但是只能有一个定义(关于#include的影响见15.2.3节)。

在同一实体的所有声明中,实体的类型必须保持一致。因此,下面的小片段包含两处错误:

int count;
int count;		//错误:重定义
extern int error_number;
extern short error_number;		//错误:类型不匹配

下面的语句则是正确的(关于extern的用法见15.2节):

extern int error_number;
extern int error_number;	//OK:多次声明

有的定义语句为它们定义的实体显式地赋“值”,例如:

struct Date{int d,m, y;};
using Point = std::complex<short>;	//Point是类型std::complex<short>的别名
int day(Date* p){return p->d;}
const double pi{3.1415926535897};

对于类型、别名、模板、函数和常量来说,这个“值”是不变的;而对于非const数据类型来说,初始值可能会在稍后被改变。例如:

void f()
{
	int count{1};					//把count初始化为1
	const char* name{“Bjarne”};		//name是个变量,它所指的对象是个常量(见7.5节)
	count = 2;						//把2赋给count
	name = “Marian”;
}

在这些定义语句中,只有两个没有指定值:

char ch;
string s;

关于变量何时以及以何种方式被赋予默认值请见6.3.5节和17.3.3节。对于任意的声明语句来说,只要它为变量指定了值,就是一条定义语句。

6.3.1 声明的结构

C++语法规定了声明语句的结构(§ iso.A)。这套语法最早从C的语法演化而来,历经四十多年的发展,变得相当复杂。在不做什么根本性简化的前提下,我们可以认为一条声明语句(依次)包含5个部分:

  • 可选的前置修饰符(比如static和virtual)
  • 基本类型(比如vector和const int)
  • 可选的声明符,可包含一个名字(比如p[7]、n和*(*)[])
  • 可选的后缀函数修饰符(比如const和noexcept)
  • 可选的初始化器或函数体(比如 ={7, 5, 3}和{return x;})

除了函数和名字空间的定义外,其他声明语句都以分号结束。请考虑下面这个C风格字符串数组的定义语句

const char* kings[] = {“Antigonus”, “Seleucus”, “Ptolemy”};

此例中,基本类型是const char,声明符是*kings[],初始化器是=及其后的{}列表。

修饰符是指声明语句中最开始的关键字,如virtual(见3.2.3节和20.3.2节)、extern(见15.2节)和constexpr(见2.2.3节)等。修饰符的作用是指定所声明对象的某些非类型属性。

声明符由一个名字和一些可选的声明运算符组成。最常用的声明运算符包括:

声明运算符
前缀*指针
前缀*const常量指针
前缀*volatilevolatile指针
前缀&左值引用(见7.7.1节)
前缀&&右值引用(见7.7.2节)
前缀auto函数(使用后置返回类型)
前缀[ ]数组
前缀( )函数
前缀->从函数返回

如果上面这些声明符都是前缀或者都是后缀的话,它们的用法就简单了。但实际上,*、[]和()的用法与在表达式中一致(见10.3节)。因此,是前缀,而[]和()是后缀。后缀声明符的绑定效果比前缀声明符更紧密,所以charkings[]是char指针的数组,而char(*kings)[]是指向char数组的指针。如果我们想声明“数组的指针”或者“函数的指针”,则必须使用括号加以限定,具体的例子请见7.2节。

请注意,在声明语句中不允许省略数据类型。例如:

const c = 7;		//错误:缺少数据类型

gt(int a, int b)	//错误:缺少数据类型
{
	return (a>b)?a:b;
}

unsigned ui;		//OK:”unsigned”即”unsigned int”
long li;			//OK:”long”即”long int”

早期版本的C和C++允许前面两个例子所示的用法,它们认为当程序员没有指定数据类型时,int是默认的类型(见44.3节);但是标准的C++不允许这样做。所谓的“隐式int”规则会让源程序充满不可捉摸的错误,并且显得杂乱无章。

有的类型名字包含多个关键字,比如long long和volatile int;还有一些类型名字看起来不像个名字,比如decltype(f(x))(表示函数f(x)的返回值类型,见6.3.6.3节)。
41.4节介绍volatile修饰符
6.2.9节介绍alignas()修饰符

6.3.2 声明多个名字

C++允许在同一个声明语句中声明多个名字,其中包含逗号隔开的多个声明符即可。例如,我们能以如下方式声明两个整数:

int x, y;	//int x; int y;

读者千万要注意,在声明语句中,运算符只作用于紧邻的一个名字,对于后续的其他名字是无效的。例如:

int* p, y;		//准确的含义是int* p; int y; 而非int* y
int x, *q;		//int x; int* q;
int v[10], *pv;		//int v[10]; int* pv;

上面这些示例在同一条声明语句中包含了多个名字且加入了某些特殊的声明符,这会让程序看起来有点难懂,实际编程过程中最好避免这种用法。

6.3.3 名字

一个名字(标识符)包含若干字母和数字,第一个字符必须是字母,其中,我们把下划线_也看成是字母。C++对于名字中所含的字符数量未作限定。但是在具体实现中,某些部分并不受编译器的控制(尤其是链接器),而这些部分有可能会限制名字中字符的多少。某些运行时环境要求扩展或缩减能出现在标识符中的字符集规模。此时,对可接受字符的扩充(比如允许名字中出现字符$)会造成程序无法移植。C++关键字(比如new和int,见6.3.3.1节)不能用作用户自定义实体的名字。一些有效的标识符如下所示:

hello			this_is_a_most_unusually_long_identifier_that_is_better_avoided
DEFINED			foD			bAr			u_name		HorseSense
var0			var1		CLASS		_class		____

下面这些字符序列不能作为标识符:

012			a fool		$sys		class		3var
pay.due		foo~bar		.name		if

以下划线开头的非局部名字表示具体实现及运行时环境中的某些特殊功能,应用程序中不应该使用这样的名字。类似地,包含双下划线(__)的名字和以下划线开头紧跟大写字母的名字(比如_Foo)都有特殊用途(见17.6.4.3节)。

编译器在编写代码时总是优先寻找能构成名字的最长的字符串。因此,var10整体是个名字,而不是名字var后跟着数字10。同样,elseif也是个名字,而非关键字else后跟着关键值if。

标识符的命名区分大小写,因此Count和count是两个不同的名字。显然,仅靠字母的大小写来区分名字不太合适。一般情况下,我们应该尽量避免使用过于相似的名字。举个例子,在很多字体中,字母“o”的大写形式(O)与数字0很难区分,字母“L”的小写形式(l)、字母“i”的大写形式(I)与数字1也很难区分。因此I0、IO、I1、Il和I1l显然是一组非常糟糕的名字。不是所有字体都有这个问题,但大多数确实如此。

在一个范围较大的作用域中,我们应该使用相对较长且明确含义的名字,比如vector、Window_with_border和Department_number。然而,在范围较小的作用域中使用一些长度较短但是约定俗成的名字也不失为一种好的选择,这些名字包括x、i、p等。函数(第12章)、类(第16章)和名字空间(见14.3.1节)可以帮助我们限定一个较小的作用域。通常,我们令那些频繁使用的名字相对较短,而让较长的名字对应一些很少用到的实体。

在为实体命名时,我们应该尽量让名字反映实体的含义而非其实现细节。例如,当我们用vector存储电话号码时(见4.4节),变量的名字用phone_book比用number_book更好。在使用某些具体动态类型系统或弱类型系统的语言编程时,程序员习惯在实体的名字中掺杂进类型信息(比如用pcname作为某个char*的名字,或者用icount作为某个int计数变量的名字),但是在C++中我们不建议这样做:

  • 把类型信息加到名字里降低了程序的抽象水平,尤其是不利于泛型编程(基本机理是其中的某个名字可以指向不同类型的实体)。
  • 编译器比程序员更擅长记录和追踪类型信息。
  • 一旦你想改变某个名字的类型(用std::string存放名字),就必须更改程序中所有用到该名字的地方(否则,已经嵌入名字的类型信息就名不副实了)。
  • 随着你用到的类型越来越多,你设计的缩写集会越来越大,有时含糊不清,有时过于啰嗦。
    简而言之,为标识符命名称得上是一门艺术。

程序员最好遵循某些约定俗称的命名风格,并且坚持下去,不要轻易改变。例如,用户自定义类型名的首字母大写,非类型实体名的首字母小写(比如Shape和current_token)。又如在宏定义中全都使用大写字母(前提是当你不得不使用宏时,见12.6节,比如HACK),在其他场合绝对不要这样做(即使非宏常量也不行)。用下划线把标识符中的单词隔开,number_of_elements比numberOfElements的可读性更好。然而,保持命名风格的统一也不是一件容易的事,毕竟程序通常是由很多来源不同的片段组合而成的,它们遵循的风格可能各不相同,各有各的道理。谨记对缩写的使用应该保持一致。请注意,C++语言和标准库中的类型名字都是小写,有时候这条线索可以帮助我们判断某个类型名字是否来源于C++标准。

6.3.3.1 关键字

C++的关键字如下表所示:
在这里插入图片描述
此外,export被留在以后使用。

6.3.4 作用域

声明语句为作用域引入了一个新名字,换句话说,某个名字只能在程序文本的某个特定区域使用。

  • 局部作用域(local scope):函数(第12章)或lambda表达式(见11.4节)中声明的名字称为局部名字(local name)。局部名字的作用域从声明处开始,到声明语句所在的块结束为止。其中块(block)是指用一对{}包围的代码片段。对于函数和lambda表达式最外层的块来说,参数名字是其中的局部名字。
  • 类作用域(class scope):如果某个类位于任意函数、类(第16章)和枚举类(见8.4.1节)或其他名字空间的外部,则定义在该类中的名字称为成员名字(member name)或类成员名字(class member name)。类成员名字的作用域从类声明的{开始,到类声明的}结束为止。
  • 名字空间作用域(namespace scope):如果某个名字空间位于任意函数(第12章)、lambda表达式(见11.4节)、类(第16章)和枚举类(见8.4.1节)或其他名字空间的外部,则定义在该名字空间中的名字为名字空间成员名字(namespace member name)。名字空间成员名字的作用域从声明语句开始,到名字空间结束为止。名字空间能被其他翻译单元访问(见15.2节)。
  • 全局作用域(global scope):定义在任意函数、类(第16章)、枚举类(见8.4.1节)和名字空间(见14.3.1节)之外的名字称为全局名字(global name)。全局名字的作用域从声明处开始,到声明语句所在的文件末尾为止。全局名字能被其他翻译单元访问(见15.2节)。从技术上来说,全局名字空间也是一种名字空间。因此,我们可以把全局名字看成是一种特殊的名字空间成员名字。
  • 语句作用域(statement scope):如果某个名字定义在for、while、if和switch语句的()部分,则该名字位于语句作用域中。它的作用域范围从声明处开始,到语句结束为止。语句作用域中的所有名字都是局部名字。
  • 函数作用域(function scope):标签(见9.6节)的作用域是从声明它开始到函数体结束。

在块内声明的名字能隐藏外层块及全局作用域中的同名声明。换句话说,一个已有的名字能在块内被重新定义以指向另外一个实体。退出块后,该名字恢复原来的含义。例如:

int x;			//全局变量x
void f()
{
	int x;		//局部变量x隐藏了全局变量x
	x = 1;		//为局部变量x赋值
	{
		int x;	//隐藏了上一个局部变量x
		x = 2;	//为第二个局部变量x赋值
	}
	x = 3;		//为第一个局部变量x赋值
}
int* p = &x;	//获取全局变量x的地址

隐藏名字的现象在规模较大的程序中比较普遍,很难避免。然而,程序的读者经常会遗忘某个名字被隐藏(或者说遮住,shadowed)的事实。由名字隐藏造成的程序错误不太多,因此一旦出错极难发现。程序员应该尽量避免隐藏名字。如果你给全局变量或者大函数中的局部变量起类似于i或x的名字,无异于自找麻烦。

我们可以使用作用域解析运算符::访问被隐藏了的全局名字,例如:

int x;

void f2()
{
	int x = 1;	//隐藏全局变量x
	::x = 2;	//为全局变量x赋值
	x = 2;		//为局部变量x赋值
	//...
}

我们无法使用被隐藏的局部名字。

非类成员名字的作用域始于它的声明点,即完整的声明符之后且初始化器之前的位置。这一规定意味着我们甚至能用某个名字作为它自己的初始值。例如:

int x = 97;

void f3()
{
	int x = x;	//不合理:赋给x的值是它自己的未初始化的值
}

一个严谨的编译器应该能在遇到变量未初始化即使用的现象时发出警告。

有一种现象看起来有点奇怪,但却是合理的,即在同一个块内有可能同一个名字所指的是两个完全不同的实体,并且我们没有使用::运算符。例如:

int x = 11;

void f4()			//不合理:在同一个作用域内使用了两个名字都是x的对象
{
	int y = x;		//使用全局变量x,结果是y=11
	int x = 22;
	y = x;			//使用局部变量x,结果是y=22
}

再次提醒,在你的程序中最好避免出现这种小问题。

我们通常认为函数的实参是声明在函数的最外层块中的,例如:

void f5(int x)
{
	int x;	//错误
}

因为x在同一个作用域中定义了两次,所以上述程序存在错误。

在for语句中引入的名字是该语句的局部名字(位于语句作用域内)。因此,在同一个函数内,我们可以在好几个循环中使用同一个便于理解的名字。例如:

void f(vector<string>& v, list<int>& lst)
{
	for(const auto& x:v) cout<<x<<’\n’;
	for(auto x:lst) cout<<x<<’\n’;
	for(int i = 0, i!=v.size(),++i)cout<<v[i]<<’\n’;
	for(auto i:{1, 2, 3, 4, 5, 6, 7}) cout<<i<<’\n’;
}

在这个函数中,不存在名字冲突。

如果在if语句的分支中有一条声明语句,并且它是该分支唯一的语句,则这种用法是不允许的(见9.4.1节)。

6.3.5 初始化

顾名思义,初始化器就是对象在初始状态下被赋予的值。初始化器有四种可能的形式:

X a1{v};
X a2 = {v};
X a3 = v;
X a4(v);

在这些形式中,只有第一种不受任何限制,在所有场景中都能使用。我强烈建议程序员使用这种形式为变量赋初值,它含义清晰,与其他形式相比不太容易出错。不过,第一种初值形式(a1)在C++11新标准中刚刚被提出,因此在老代码中使用的都是后面三种形式。其中,使用=的两种形式是从C语言继承而来的。俗话说习惯成自然,即使是我,也会(不总是)在遇到用简单值初始化简单变量的时候,不自觉地使用=。例如:

int x1 = 0;
char c1 = ‘z’;

然而,在面对稍微复杂一点的情况时,我还是建议读者使用{}。使用{}的初始化称为列表初始化(list initialization),它能防止窄化转换(§ iso.8.5.4)。这句话的意思是:

  • 如果一种整型存不下另一种整型的值,则后者不会被转换成前者。例如,允许char到int的类型转换,但是不允许int到char的类型转换。
  • 如果一种浮点型存不下另一种浮点型的值,则后者不会被转换成前者。例如,允许float到double的类型转换,但是不允许double到float的类型转换。
  • 浮点型的值不能转换成整型值。
  • 整型值不能转换成浮点型的值。

例如:

void f(double val, int val2)
{
	int x2 = val;	//如果val==7.9,则x2的值变为7
	char c2 = val2;	//如果val2==1025,则c2的值变为1

	int x3{val};	//错误:可能发生截断
	char c3{val2};	//错误:可能发生窄化转换

	char c4{24};	//OK:24能精确地表达成一个char
	char c5{264};	//错误(假定char占8位):264不能表示成一个char

	int x4{2.0};	//错误:不允许double到int的类型转换
	//...
}

关于内置类型的转换规则请见10.5节。

当我们使用auto关键字从初始化器推断变量的类型时,没必要采用列表初始化的方式。而且如果初始化器是{}列表,则推断得到的数据类型肯定不是我们想要的结果(见6.3.6.2节)。例如:

auto z1{99};	//z1的类型是initializer_list<int>
auto z2 = 99;	//z2的类型是int

因此当使用auto的时候应该选择=的初始化形式。

当我们构建某些类的对象时,可能有两种形式:一种是提供一组初始值;另一种是提供几个实参,这些实参不一定是实际存储的值,可能有别的含义。一个典型的例子是存放整数的vector:

vector<int> v1{99};	//v1包含1个元素,该元素的值是99
vector<int> v2(99);	//v2包含99个元素,每个元素都取默认值0

在上述代码中,我采用(99)的形式显式调用构造函数以实现第二种效果。大多数数据类型不提供这种语义含糊的初始化形式,即使是很多vector也不会;例如:

vector<string> v1{“hello!};	//v1含有1个元素,该元素的值是“hello!”
vector<string> v2(“hello!);	//错误:vector的任何构造函数都不接受字符串字面值常量作为参数

因此,除非你有充分的理由,否则最好使用{}初始化。

空初始化器列表{}指定使用默认值进行初始化,例如:

int x4{};			//x4被赋值为0
double d4{};		//d4被赋值为0.0
char* p{};			//p被赋值为nullptr
vector<int> v4{};	//v4被赋值为一个空向量
string s4{};		//s4被赋值为“”

大多数数据类型都有默认值。对于整数类型来说,默认值是数字0的某种适当形式。指针的默认值是nullptr(见7.2.2节)。用户自定义类型的默认值(如果存在的话)由该类型的构造函数决定(见17.3.3节)。

对于用户自定义类型来说,直接初始化(允许隐式类型转换)和拷贝初始化(不允许隐式类型转换)可能会有所不同。相关细节请见16.2.6节。

特定类型对象的初始化问题将在后续章节中逐一介绍:

  • 指针,见7.2.2节、7.3.2节和7.4节。
  • 引用,见7.1.1节(左值)和7.7.2节(右值)。
  • 数组,见7.3.1节和7.3.2节。
  • 常量,见10.4节。
  • 类,见17.3.1节(不使用构造函数),17.3.2节(使用构造函数),17.3.3节(默认构造函数),17.4节(成员和基类),17.5节(拷贝和移动)。
  • 用户定义的容器,见17.3.4节。

6.3.5.1 缺少初始化器

包括内置类型在内的很多类型都可能遇到缺少初始化器的情况。如果这真的发生了(事实上经常发生),那么事情会变得有点复杂。如果你不想面对这种复杂的情况,一定要时时记得初始化变量。未初始化变量的一种最有用的场景是当我们使用一个大的输入缓冲区时,例如:

constexpr int max = 1024*1024;
char buf[max];
some_stream.get(buf, max);	//读入最多max个字符到buf

要想初始化buf其实很容易:

char buf[max] {};		//把每个字符都初始化成0

但是这样做显然是多余的,而且可能会对程序性能造成非常严重的影响。无论如何,程序员应该尽量避免直接操作冲区,并且除非能百分之百确定(比如通过度量时间)未初始化缓冲区远优于初始化缓冲区,否则不要轻易地让缓冲区处于未初始化的状态。

如果没有指定初始化器,则全局变量(见6.3.4节)、名字空间变量(见14.3.1节)、局部static变量(见12.1.8节)和static成员(见16.2.12节)(统称为静态对象(static object))将会执行相应数据类型的列表{}初始化。例如:

int a;		//等同于‘‘int a{};’’,因此a的值变为0
double d;	//等同于‘‘double d{};’’,因此d的值变为0.0

对于局部变量和自由存储上的对象(有时也称为动态对象(dynamic object)或堆对象(heap object),见11.2节)来说,除非它们位于用户自定义类型的默认构造函数中(见17.3.3节),否则不会执行默认初始化。例如:

void f()
{
	int x;				//x没有一个定义良好的值
	char buf[1024];			//buf[i]没有一个定义良好的值

	int* p{new int};			//*p没有一个定义良好的值
	char* q{new char[1024]};	//q[i]没有一个定义良好的值

	string s;				//因为string的默认构造函数,所以s==“”
	vector<char> v;			//因为vector的默认构造函数,所以v=={}

	string* ps{new string};		//因为vector的默认构造函数,所以*ps是“”
	//...
}

如果你想对内置类型的局部变量或者用new创建的内置类型的对象执行初始化,利用{}的形式,例如:

void ff()
{
	int x[];				//x的值变为0
	char buf[1024]{};		//对于任意i,buf[i]的值变为0

	int* p{new int{10}};		//*p的值变为10
	char* q{new char[1024]{}};	//对于任意i,q[i]的值变为0

	//...
}

对于上面所示的数组和类来说,其成员将执行默认初始化。

6.3.5.2 初始化器列表

到目前为止,我们已经讨论了没有初始化器和只有一个初始化器的情况。复杂一点的对象可能需要多于一个初始化器,此时就要用到一对花括号{}界定的初始化器列表了。例如:

int a[] = {1, 2};							//数组初始化器
struct S{int x, string s};
S s = {1, “Helios”};						//结构的初始化器
complex<double> z = {0, pi};				//使用构造函数
vector<double> v = {0.0, 1.1, 2.2, 3.3};	//使用列表构造函数

C风格的数组初始化方式见7.3.1节,C风格的结构初始化方式见8.2节,使用构造函数初始化用户自定义类型的方式见2.3.2节和16.2.5节,初始化器列表构造函数见17.3.4节。

在上面的例子中,符号=实际上是多余的。不过有的人愿意保留=,以说明我们是在用一组值初始化一组成员变量。

在有的例子中,我们也可以使用函数风格的实参列表(见2.3节和16.2.5节),例如:

complex<double>z(0, pi);		//使用构造函数
vector<double>v(10, 3.3);		//使用构造函数:v包含10个元素,每个元素都初始化为3.3

在声明语句中,一对空括号()通常表示“函数”(见12.1节)。因此,如果想显式地表达“执行默认初始化”的意愿,你需要使用{}。例如:

complex<double> z1(1, 2);		//函数风格的初始化器(用构造函数执行初始化)
complex<double> f1();			//函数声明

complex<double> z2{1, 2};		//用构造函数初始化成{1, 2}
complex<double> f2{};			//用构造函数初始化成默认值{0, 0}

请注意,当我们使用{}符号进行初始化时,不会进行窄化转换(见6.3.5节)。

在使用了auto的语句中,{}列表的类型被推断为std::initializer_list。例如:

auto x1{1, 2, 3, 4};			//x1的类型是initializer_list<int>
auto x1{1.0, 2.25, 3.5};		//x2的类型是initializer_list<double>
auto x1{1.0, 2};				//错误:无法推断{1.0, 2}的类型(见6.3.6.2节)

6.3.6 推断类型:auto和decltype()

C++语言提供了两种从表达式中推断数据类型的机制:

  • auto根据对象的初始化器推断对象的数据类型,可能是变量、const或者constexpr的类型。
  • decltype(expr)推断的对象不是一个简单的初始化器,有可能是函数的返回类型或者类成员的类型。
    这里所谓的推断其实非常简单:auto和decltype()只是简单地报告一个编译器已知的表达式的类型。

6.3.6.1 auto类型修饰符

当声明语句中的变量含有初始化器时,我们无须显式地指定变量的类型,只要让变量取其初始化器的类型即可。例如:

int a1 = 123;
char a2 = 123;
auto a3 = 123;	//a3的类型是int

整数字面值常量123的类型是int,因此a3的类型就是int。换句话说,我们可以把auto看成是初始化器类型的占位符。

在像123这样简单的表达式中,用auto代替int看起来没什么大不了的。但是,表达式的类型越难读懂、越难书写,auto就越有用。例如:

template<class T> void f1(vector<T>& arg)
{
	for(vector<T>::iterator p = arg.begin(); p!=arg.end(); ++p)
		*p = 7;
	for(auto p = arg.begin(); p!=arg.end(); ++p)
		*p = 7;
}

对于上面的程序来说,使用auto显然是更好的选择。它易读易写,且能在一定程度上适应代码的变化。例如,如果我们把arg的类型更改成list,则使用auto的循环仍然可以正常工作,而第一个循环需要重写。因此,在较小的作用域中,建议程序员优先选择使用auto。

如果作用域的范围较大,则显式地指定类型有助于定位错误。换句话说,与使用明确的类型名相比,使用auto可能会使得定位类型错误的难度增大。例如:

void f(double d)
{
	constexpr auto max = d+7;
	int a[max];				//错误:数组边界不是整数
	//...
}

为了解决auto可能造成的影响,最常规的做法是保持函数的规模较小,这也被证明是一种行之有效的方法(见12.1节)。

我们可以为推断出的类型添加修饰符或说明符(见6.3.1节),比如const和&(引用,见7.7节)。例如:

void f(vector<int>& v)
{
	for(const auto& x : v){	//x的类型是const int&
		//...
	}
}

在此例中,auto推断为v的元素的类型,即int。

请注意,表达式的类型永远不会是引用类型,因为表达式会隐式地执行解引用操作(见7.7节)。例如:

void g(int& v)
{
		auto x = v;			//x的类型是int,而不是int&
		auto& y = v;		//y的类型是int&
}

6.3.6.2 auto与{ }列表

如果我们正在初始化某个对象,那么当提到它的类型时必须同时考虑两部分:对象本身的类型以及初始化器的类型。例如:

char v1 = 12345;	//12345的类型是int
int v2 = ‘c’;		//‘c’的类型是char
T v3 = f();	

使用{}初始化器列表可以尽可能减少意料之外的类型转换:

char v1{12345};		//错误:窄化转换
int v2{‘c’};		//正确:隐式地char->int类型转换
T v3 {f()};			//当且仅当f()的类型能被隐式地转换成T时,该语句有效

当我们使用auto关键字时,只涉及一种类型(初始值的类型),此时使用=是安全的,不会有什么问题:

auto v1 = 12345;	//v1的类型是int
auto v2 = ‘c’;		//v2的类型是char
auto v3 = f();		//v3也会是某种适当的类型

事实上,当声明语句中有auto关键字时,=是比{}更好的选择,因为前者的结果可能并非我们所愿:

auto v1{12345};	//v1的类型是int的列表
auto v2{‘c’};		//v2的类型是char的列表
auto v3 {f()};		//v3是某种类型的列表

这是符合逻辑的。请考虑如下情况:

auto x0{};		//错误:无法推断类型
auto x1{1};		//int的列表,包含1个元素
auto x2{1, 2};		//int的列表,包含2个元素
auto x3{1, 2, 3};		//int的列表,包含3个元素

由同一种类型T的元素组成的列表类型是initializer_list(见3.2.1.3节和11.3.3节)。应该庆幸x1的类型没有被推断成int,否则的话我们真不知道x2和x3该怎么办了。

总之,只要我们不是期望得到某种“列表”类型,就应该选择=而非{}。

6.3.6.3 decltype()修饰符

当有一个合适的初始化器的时候可以使用auto。但是很多时候我们既想推断得到类型,又不想在此过程中定义一个初始化的变量,此时,我们应该使用声明类型修饰符decltype(expr)。其中,推断所得的结果是expr的声明类型。这种用法在泛型编程中很有效。请考虑这样一个问题:如果我们想编写一个函数令其执行两个矩阵的加法运算,但是两个矩阵的元素类型可能不同,那么相加之后所得结果的类型应该是什么呢?当然是矩阵,但是这个结果矩阵的元素是什么类型?最自然的回答是:结果矩阵的元素类型应该是对应元素求和后的类型。因此,我们的声明如下所示:

template<class T, class U>
auto operator+(const Matrix<T>& a, const Matrix<U>& b)->Matrix<decltype(T{} + U{})>;

在这个声明中我使用了后置返回类型语法(见12.1节),以便通过Matrix<decltype(T{} + U{})>推断出函数的返回类型。换句话说,函数的结果是一个Matrix,Matrix的元素类型由T{}+U{}推断得到。

在该函数的定义部分,我再一次使用decltype()来表示Matrix的元素类型:

template<class T, class U>
auto operator+(const Matrix<T>& a, const Matrix<U>& b)->Matrix<decltype(T{} + U{})>
{
	Matrix<decltype(T{}+U{})> res;
	for(int i = 0; i!=a.rows(); ++i)
		for(int j = 0; j!=a.cols(); ++j)
			res(i, j) +=a(i, j) + b(i, j);
	return res;
}
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Hank_W

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值