C++17标准对聚合类型的定义做出了大幅修改,即从基类公开且非虚继承的类也可能是一个聚合。同时聚合类型还需要满足常规条件。
1.没有用户提供的构造函数。
2.没有私有和受保护的非静态数据成员。
3.没有虚函数。
在新的扩展中,如果类存在继承关系,则额外满足以下条件。
4.必须是公开的基类,不能是私有或者受保护的基类。
5.必须是非虚继承。
请注意,这里并没有讨论基类是否需要是聚合类型,也就是说基类是否是聚合类型与派生类是否为聚合类型没有关系,只要满足上述5个条件,派生类就是聚合类型。在标准库<type_traits>中提供了一个聚合类型的甄别办法is_aggregate,它可以帮助我们判断目标类型是否为聚合类型:
#include <iostream>
#include <string>
class MyString : public std::string {};
int main()
{
std::cout << "std::is_aggregate_v<std::string> = "
<< std::is_aggregate_v<std::string> << std::endl;
std::cout << "std::is_aggregate_v<MyString> = "
<< std::is_aggregate_v<MyString> << std::endl;
}在上面的代码中,先通过std::is_aggregate_v判断std::string是否为聚合类型,根据我们对std::string的了解,它存在用户提供的构造函数,所以一定是非聚合类型。然后判断类MyString是否为聚合类型,虽然该类继承了std::string,但因为它是公开继承且是非虚继承,另外,在类中不存在用户提供的构造函数、虚函数以及私有或者受保护的数据成员,所以MyString应该是聚合类型。编译运行以上代码,输出的结果也和我们判断的一致:
std::is_aggregate_v<std::string> = 0
std::is_aggregate_v<MyString> = 1由于聚合类型定义的扩展,聚合对象的初始化方法也发生了变化。过去要想初始化派生类的基类,需要在派生类中提供构造函数,例如:
#include <iostream>
#include <string>
class MyStringWithIndex : public std::string {
public:
MyStringWithIndex(const std::string& str, int idx) : std::string(str), index_(idx) {}
int index_ = 0;
};
std::ostream& operator << (std::ostream &o, const MyStringWithIndex& s)
{
o << s.index_ << ":" << s.c_str();
return o;
}
int main()
{
MyStringWithIndex s("hello world", 11);
std::cout << s << std::endl;
}在上面的代码中,为了初始化基类我们不得不为MyStringWithIndex提供一个构造函数,用构造函数的初始化列表来初始化std::string。现在,由于聚合类型的扩展,这个过程得到了简化。需要做的修改只有两点,第一是删除派生类中用户提供的构造函数,第二是直接初始化:
#include <iostream>
#include <string>
class MyStringWithIndex : public std::string {
public:
int index_ = 0;
};
std::ostream& operator << (std::ostream &o, const MyStringWithIndex& s)
{
o << s.index_ << ":" << s.c_str();
return o;
}
int main()
{
MyStringWithIndex s{ {"hello world"}, 11 };
std::cout << s << std::endl;
}删除派生类中用户提供的构造函数是为了让MyStringWithIndex成为一个C++17标准的聚合类型,而作为聚合类型直接使用大括号初始化即可。MyStringWithIndex s{ {"hello world"}, 11}是典型的初始化基类聚合类型的方法。其中{"hello world"}用于基类的初始化,11用于index_的初始化。这里的规则总是假设基类是一种在所有数据成员之前声明的特殊成员。所以实际上,{"hello world"}的大括号也可以省略,直接使用MyStringWithIndex s{ "hello world", 11}也是可行的。另外,如果派生类存在多个基类,那么其初始化的顺序与继承的顺序相同:
#include <iostream>
#include <string>
class Count {
public:
int Get() { return count_++; }
int count_ = 0;
};
class MyStringWithIndex :
public std::string,
public Count {
public:
int index_ = 0;
};
std::ostream& operator << (std::ostream &o, MyStringWithIndex& s)
{
o << s.index_ << ":" << s.Get() << ":" << s.c_str();
return o;
}
int main()
{
MyStringWithIndex s{ "hello world", 7, 11 };
std::cout << s << std::endl;
std::cout << s << std::endl;
}在上面的代码中,类MyStringWithIndex先后继承了std::string和Count,所以在初始化时需要按照这个顺序初始化对象。{ "hello world", 7, 11}中字符串"hello world"对应基类std::string,7对应基类Count,11对应数据成员index_。
虽然扩展的聚合类型给我们提供了一些方便,但同时也带来了一个兼容老代码的问题,请考虑以下代码:
#include <iostream>
#include <string>
class BaseData {
int data_;
public:
int Get() { return data_; }
protected:
BaseData() : data_(11) {}
};
class DerivedData : public BaseData {
public:
};
int main()
{
DerivedData d{};
std::cout << d.Get() << std::endl;
}以上代码使用C++11或者C++14标准可以编译成功,而使用C++17标准编译则会出现错误,主要原因就是聚合类型的定义发生了变化。在C++17之前,类DerivedData不是一个聚合类型,所以DerivedData d{}会调用编译器提供的默认构造函数。调用DerivedData默认构造函数的同时还会调用BaseData的构造函数。虽然这里BaseData声明的是受保护的构造函数,但是这并不妨碍派生类调用它。从C++17开始情况发生了变化,类DerivedData变成了一个聚合类型,以至于DerivedData d{}也跟着变成聚合类型的初始化,因为基类BaseData中的构造函数是受保护的关系,它不允许在聚合类型初始化中被调用,所以编译器无奈之下给出了一个编译错误。如果读者在更新开发环境到C++17标准的时候遇到了这样的问题,只需要为派生类提供一个默认构造函数即可。
在前面我们提到没有用户提供的构造函数是聚合类型的条件之一,但是请注意,用户提供的构造函数和用户声明的构造函数是有区别的,比如:
#include <iostream>
struct X {
X() = default;
};
struct Y {
Y() = delete;
};
int main() {
std::cout << std::boolalpha
<< "std::is_aggregate_v<X> : " << std::is_aggregate_v<X> << std::endl
<< "std::is_aggregate_v<Y> : " << std::is_aggregate_v<Y> << std::endl;
}用C++17标准编译运行以上代码会输出:
std::is_aggregate_v<X> : true
std::is_aggregate_v<Y> : true由此可见,虽然类X和Y都有用户声明的构造函数,但是它们依旧是聚合类型。不过这就引出了一个问题,让我们将目光放在结构体Y上,因为它的默认构造函数被显式地删除了,所以该类型应该无法实例化对象,例如:
Y y1; // 编译失败,使用了删除函数但是作为聚合类型,我们却可以通过聚合初始化的方式将其实例化:
Y y2{}; // 编译成功编译成功的这个结果显然不是类型Y的设计者想看到的,而且这个问题很容易在真实的开发过程中被忽略,从而导致意想不到的结果。除了删除默认构造函数,将其列入私有访问中也会有同样的问题,比如:
struct Y {
private:
Y() = default;
};
Y y1; // 编译失败,构造函数为私有访问
y y2{}; // 编译成功请注意,这里Y() = default;中的= default不能省略,否则Y会被识别为一个非聚合类型。
为了避免以上问题的出现,在C++17标准中可以使用explicit说明符或者将= default声明到结构体外,例如:
struct X {
explicit X() = default;
};
struct Y {
Y();
};
Y::Y() = default;这样一来,结构体X和Y被转变为非聚合类型,也就无法使用聚合初始化了。不过即使这样,还是没有解决相同类型不同实例化方式表现不一致的尴尬问题,所以在C++20标准中禁止聚合类型使用用户声明的构造函数,这种处理方式让所有的情况保持一致,是最为简单明确的方法。同样是本节中的第一段代码示例,用C++20环境编译的输出结果如下:
std::is_aggregate_v<X> : false
std::is_aggregate_v<Y> : false值得注意的是,这个规则的修改会改变一些旧代码的意义,比如我们经常用到的禁止复制构造的方法:
struct X {
std::string s;
std::vector<int> v;
X() = default;
X(const X&) = delete;
X(X&&) = default;
};上面这段代码中结构体X在C++17标准中是聚合类型,所以可以使用聚合类型初始化对象。但是升级编译环境到C++20标准会使X转变为非聚合对象,从而造成无法通过编译的问题。一个可行的解决方案是,不要直接使用= delete;来删除复制构造函数,而是通过加入或者继承一个不可复制构造的类型来实现类型的不可复制,例如:
struct X {
std::string s;
std::vector<int> v;
[[no_unique_address]] NonCopyable nc;
};
// 或者
struct X : NonCopyable {
std::string s;
std::vector<int> v;
};这种做法能让代码看起来更加简洁,所以我们往往会被推荐这样做。
通过15.2节,我们知道对于一个聚合类型可以使用带大括号的列表对其进行初始化,例如:
struct X {
int i;
float f;
};
X x{ 11, 7.0f };如果将上面初始化代码中的大括号修改为小括号,C++17标准的编译器会给出无法匹配到对应构造函数X::X(int, float)的错误,这说明小括号会尝试调用其构造函数。这一点在C++20标准中做出了修改,它规定对于聚合类型对象的初始化可以用小括号列表来完成,其最终结果与大括号列表相同。所以以上代码可以修改为:
X x( 11, 7.0f );另外,前面的章节曾提到过带大括号的列表初始化是不支持缩窄转换的,但是带小括号的列表初始化却是支持缩窄转换的,比如:
struct X {
int i;
short f;
};
X x1{ 11, 7.0 }; // 编译失败,7.0从double转换到short是缩窄转换
X x2( 11, 7.0 ); // 编译成功需要注意的是,到目前为止该特性只在GCC中得到支持,而CLang和MSVC都还没有支持该特性。
虽然本章的内容不多且较为容易理解,但它却是一个比较重要的章节。因为扩展的聚合类型改版了原本聚合类型的定义,这就导致了一些兼容性问题,这种情况在C++新特性中并不多见。如果不能牢固地掌握新定义的知识点,很容易导致代码无法通过编译,更严重的可能是导致代码运行出现逻辑错误,类似这种Bug又往往难以定位,所以对于扩展的聚合类型我们尤其需要重视起来。