一、定义抽象数据类型
- 类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。
- 类的接口包括用户所能执行的操作。类的实现包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
- 封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
1.1 定义抽象数据类型
类的用户
- 程序员们常把运行其程序的人称作用户(user)。类似的,类的设计者也是为其用户设计并实现类的一个人。显然类的用户是程序员,而非应用程序的最终使用者。
- 当我们设计类的接口时,应该考虑如何才能使得类易于使用。而当我们使用类时,不应该顾及类的实现机理。
- 作为一个设计良好的类,既要有直观且易于使用的接口,也必须具备高效的实现过程。
1.2 定义一个书籍类
#pragma once
#include<string>
struct Sales_data
{
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream& print(std::ostream, const Sales_data&);
std::istream& read(std::istream, Sales_data&);
- 定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,而定义既可以在类的内部也可以在类的外部。
- 作为接口组成部分的非成员函数,它们的定义和声明都在类的外部。
- 定义在类内部的函数是隐式的inline函数。
引入this
- 成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。
- 在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无需通过成员运算符来做到这一点,因为this所指的正是这个对象。
- 任何自定义命名为this的参数或变得行为都是非法的。因为this总是指向对象本身,所以this是一个常量指针,我们不允许改变this中保存的地址。
引入const成员函数
- 默认情况下this的类型是指向类类型非常量版本的常量指针。例如在上文Sales_data成员函数中,this的类型是Sales_data * const。尽管this是隐式的,但它仍需要遵循初始化规则,意味着(在默认情况下)我们不能把this绑定到一个常量对象上。这一情况导致我们不能在一个常量对象上调用普通的成员函数。
- 实验代码:
#pragma once
#include<string>
struct Sales_data
{
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
int test();
};
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream& print(std::ostream, const Sales_data&);
std::istream& read(std::istream, Sales_data&);
int Sales_data::test()
{
return 0;
}
#include <iostream>
#include "Sales_data.h"
using std::cin;
using std::cout;
using std::endl;
int main()
{
const Sales_data sales;
sales.test();
cout << endl;
return 0;
}
- 运行结果:
- 那这如何是好?问题的关键在于this是隐式的并且不会出现在参数列表中,所以在哪将this声明成指向常量的指针就成为了最大的难题。C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数。
- 修改测试函数为常量成员函数,编译成功:
#pragma once
#include<string>
struct Sales_data
{
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
int test() const;
};
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream& print(std::ostream, const Sales_data&);
std::istream& read(std::istream, Sales_data&);
int Sales_data::test() const
{
return 0;
}
- 常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
类作用域和成员函数
- 类本身就是一个作用域,类的成员函数嵌套在类的作用域之内。
- 编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员函数而无需在意这些成员出现的次序。
在类的外部定义成员函数
- 和其他函数一样,在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表、函数名都得与类内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const属性。
- 同时,类外部定义的成员的名字必须包含它所属的类名。例如上文中的测试函数名定义为:
int Sales_data::test() const
{
return 0;
}
- 上述代码表示我们定义了一个test函数,而该函数被声明在类Sales_data的作用域内。当编译器看到这个函数名,就理解了这段代码是位于类的作用域内的。(注意即使函数实现在全局作用域,但是实现类成员的函数作用域类作用域,仅当访问权限为public时全局才可见)
定义一个返回this对象的函数
- 函数combine的设计初衷类似于复合赋值运算符+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数。
- combine函数实现:
Sales_data& Sales_data::combine(const Sales_data& rhs)
{
this->units_sold += rhs.units_sold;
this->revenue += rhs.revenue;
return *this;
}
- 因为内置的赋值运算符将它左侧的运算对象当成左值返回,因此为了与其保持一致combine函数必须返回引用类型。其中return语句解引用this指针以获取指向该函数的对象。
1.3 定义类相关的非成员函数
定义read和print函数
- 类的作者常常需要定义一些辅助函数,比如add、read和print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。
- 我们通常把非成员函数定声明和定义分离开。如果一个函数在概念上属于类但是不定义在类中,则它一般与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引用一个文件。
- 一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。
- 定义read和print函数:
std::istream& read(std::istream& is, Sales_data& item)
{
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
std::ostream& print(std::ostream& os, const Sales_data& item)
{
os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
return os;
}
Sales_data Sales_data::add(const Sales_data& lhs, const Sales_data& rhs)
{
Sales_data sum = lhs;
sum.combine(rhs);
return sum;
}
- 注意istream和ostream类定义在头文件< iostream >中,如果声明类的头文件不引入此头文件,会显示istream和ostream对象的"<<“以及”>>"运算符未定义。
最终代码
#pragma once
#include<iostream>
#include<string>
struct Sales_data
{
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
Sales_data add(const Sales_data& lhs, const Sales_data& rhs);
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream& print(std::ostream, const Sales_data&);
std::istream& read(std::istream, Sales_data&);
#include "Sales_data.h"
double Sales_data::avg_price() const
{
if (units_sold)
return revenue / units_sold;
return 0;
}
Sales_data& Sales_data::combine(const Sales_data& rhs)
{
this->units_sold += rhs.units_sold;
this->revenue += rhs.revenue;
return *this;
}
std::istream& read(std::istream& is, Sales_data& item)
{
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
std::ostream& print(std::ostream& os, const Sales_data& item)
{
os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
return os;
}
Sales_data Sales_data::add(const Sales_data& lhs, const Sales_data& rhs)
{
Sales_data sum = lhs;
sum.combine(rhs);
return sum;
}
#include <iostream>
#include <vector>
#include <string>
#include "Sales_data.h"
using std::cin;
using std::cout;
using std::endl;
using std::vector;
using std::string;
int main()
{
std::vector<Sales_data> item_arr1, item_arr2;
Sales_data item;
while (read(cin, item))
item_arr1.push_back(item);
std::vector<Sales_data>::iterator beg = item_arr1.begin()+1;
std::vector<Sales_data>::iterator end = item_arr1.end();
while (beg != end)
{
if ((*beg).isbn() != (*(beg - 1)).isbn())
item_arr2.push_back(*(beg - 1));
else
(*beg).combine(*(beg - 1));
beg++;
if (beg == end)
item_arr2.push_back(*(beg-1));
}
for (auto it : item_arr2)
{
print(cout, it);
cout << endl;
}
return 0;
}
- 运行结果:
1.4 构造函数
- 每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。
- 构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
- 构造函数的名字和类名相同,没有返回值类型。类可以包括多个构造函数,不同的构造函数必须在参数数量或参数类型上有所区别。
- 不同于其他成员函数,构造函数不能被声明为const的。当我们创建类的一个const对象时,直到构造函数完成初始化的过程,对象才能真正取得其"常量"属性。
合成的默认构造函数
- 类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫默认构造函数。默认构造函数无需任何实参。如果我们的类没有显式的定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
- 编译器创建的构造函数又被称为:合成的默认构造函数。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内初始化值, 用它来初始化成员。
- 否则,默认初始化该成员。
某些类不能依赖于合成的默认构造函数
- 合成的默认构造函数只适合非常简单的类。对于一个普通的类,必须定义它自己的默认构造函数。原因如下:
- 第一,编译器只有在发现类中没有任何构造函数时才会帮我们生成一个默认的构造函数,一旦我们定义了其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认的构造函数。
- 第二,类内的内置类型或复合类型的对象被默认初始化时,它们的值将是未定义的。
- 第三,有的时候编译器无法为类合成默认构造函数。当类中包含一个类类型的成员并且它没有默认的构造函数时,那么编译器将无法初始化该成员。
- 总结:只有当类内没有声明任何构造函数时,编译器才会自动地生成默认构造函数。如果类包含内置类型或复合类型的成员,只有当这些成员全部被赋予了类内初始值时,这个类才适合使用合成的默认构造函数。只有当类中包含的类类型具有默认的构造函数时,这个类才可以合成默认的构造函数。
定义Sales_data的构造函数
Sales_data() = default;
Sales_data(const std::string &s):bookNo(s){}
Sales_data(const std::string& s, unsigned n, double p) :bookNo(s), units_sold(n), revenue(n* p) {}
Sales_data(std::istream&);
= default的含义
- 在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default 来要求编译器生成构造函数。其中 = default 即可和声明一起出现在类的内部,也可以作为定义出现在类的外部。
Sales_data() = default;
- 对于类的任何成员函数,如果定义在类内则默认函数是内联的,如果定义在类外则默认情况下函数不是内联的。
- 因为在Sales_data类中我们为每个数据成员提供了类内初始值,因此我们可以直接使用默认构造函数。如果没有提供类内初始值,那么应该使用构造函数初始列表。
构造函数初始化列表
Sales_data(const std::string& s, unsigned n, double p) :bookNo(s), units_sold(n), revenue(n* p) {}
- 构造函数初始化列表就是在构造函数的参数列表后加一个冒号,然后接一个初始化列表,列表中是类的数据成员名,每个名字后加一个括号括起来的形参,表示使用这个形参初始化数据成员。
- 没有出现在构造函数初始化列表中的成员将通过相应的类内初始值(如果存在)进行初始化,或者执行默认初始化(没有类内初始值)。
- 如果你没有使用类内初始值,那么你的所有构造函数都应该显式初始化每个内置类型的成员,因为未显式初始化的内置类型成员的值是未定义的。
1.5 拷贝、赋值和析构
- 类对象被拷贝的几种情况:初始化变量、以值传递的方式传递或返回一个对象等。
- 当我们使用赋值运算符 “=” 时会发生对象的赋值操作。
- 当对象不存在时执行的销毁操作,比如一个局部对象会在创建它的块结束时被销毁,当vector对象(或者数组)销毁时存在其中的对象也会被销毁。
- 如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
某些类不能依赖于合成版本
- 对于某些类来说合成的版本无法正常工作。例如当类需要分配类对象之外的资源时,合成的版本常常会失效。
- 很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或者string的类能避免分配和释放内存带来的复杂性。
- 因此如果类包含vector或string成员,则其拷贝、赋值和销毁的合成版本能正常工作。
二、访问控制与封装
2.0 简介
- 在C++中我们使用访问说明符加强类的封装性:
- 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
- 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。
- 一个类可以包含0个或多个访问说明符,而且每种访问说明符出现的次数没有严格限制。每个说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处位置。
使用class或struct关键字
- 类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。
- 如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的。
- 如果我们使用class关键字,则定义在第一个访问说明符之前的成员是private的。
- 我们可以使用struct和class其中任何一个关键字定义类,使用它们定义类的唯一区别就是默认访问权限的不同。
2.1 友元
- 类可以允许其他类或函数访问它的非公有成员,方法是另其他类或者函数成为它的友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:
friend std::istream& read(std::istream& is, Sales_data& item);
friend std::ostream& print(std::ostream& os, const Sales_data& item);
- 由于read和print接口函数需要访问Sales_data类内封装的数据成员,因此我们将它们声明为类的友元。
- 友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
- 一般来说,最好在类定义的开始或结束前的位置集中声明友元。
封装的益处
- 使用封装后,当类的定义发生改变时我们无需更改用户代码,但是使用了该类的源文件必须重新编译。
友元的声明
- 友元的声明仅仅指定了访问的权限,而非一个通用意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
- 为了使友元函数对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此我们需要为接口函数提供除了类内部友元声明之外的独立声明。
- 许多编译器未强制限定友元函数必须在使用之前在类的外部声明,不过你最好这样做,不然如果你更换了一个有这种强制要求的编译器,你必须改变你的代码。
三、类的其他特性
3.1 类成员再探
- 如果我们定义了构造函数,我们需要使用=default告诉编译器为我们合成默认构造函数。
- 我们可以在类的内部使用inline关键字修饰函数的定义,同样也可以在类的外部使用inline关键字。虽然我们无需再声明和定义的地方同时说明inline,但这么做其实是合法的。不过最后只在类外部定义的地方说明inline,这样可以使类更容易理解。
- 和我们再头文件种定义inline函数的原因一样(编译器需要粘贴),inline成员函数也应该与相应的类定义在同一个头文件中。
重载成员函数
- 和非成员函数一样,只要成员函数之间在参数的数量或类型上有所区别,成员函数也可以被重载。
可变数据成员
- 一个可变数据成员永远不会是const,即使它是const对象的成员。因此const成员函数可以修改可变数据对象的值。
- 我们在变量的声明中加入mutable关键字做到这一点:
mutable int able;
类数据成员的初始值
private:
std::string str = "abc";
- 为类内的类类型数据成员提供类内初始值如上所示,需要根据数据成员类中定义的构造函数或者赋值方法,为类类型数据成员提供类内初始值。
- 当我们提供一个类内初始值时,必须以符号"="或花括号表示。
3.2 返回 *this 的成员函数
- 以类Sales_data为例,假如其成员函数move返回Sales_data&类型,则move函数执行后会返回对象的引用。这时我们是使用引用再调用其他成员函数,还是把引用赋值给其他对象,都是在使用原对象。
- 如果返回类型为Sales_data,那么返回的将是原对象的副本。之后无论是使用返回值调用函数还是赋值给其他对象,都是对副本的操作,而与原对象无关。
从const成员函数返回 *this
- 如果一个成员函数非const,即它可以修改*this对象。以Sales_data类为例,对于类中一个非const成员函数,它将隐式传入的this指针类型为:Sales_data * 。当非const函数返回时,由于我们的指针this为Sales_data * 。因此我们可以返回Sales_data * ,也可以返回const Sales_data * 。
- 如果一个成员函数为const函数,则它的隐式指针this类型为:const Sales_data * 。因此我们不能返回Sales_data * ,只能返回const Sales_data *。
- 总结:对于非cosnt成员函数,返回值类似可以是类对象的引用,也可以是类对象的常量引用。对于const成员函数,只能返回对象的常量引用。(指针同理)
基于const的重载
- 通过区分成员函数是否是const的,我们可以实现基于const的重载。
- const对象只能调用const函数,非const对象可以调用const函数和非const函数。当一个函数既有const版本也有非const版本,且它们的参数列表都相同时,const对象会调用const版本,非const对象优先调用非const版本。
- 因此需要注意:调用的重载函数版本不仅由实参列表决定,也由对象的类型和const等限定决定。
- 当我们使用:object.move().set() 这样连续调用成员形式时,我们要注意每一次调用后都会左值的类型,即每次调用会返回一个对象,而这个对象的类型可能发生了变化。
3.3 类类型
- 每个类定义了唯一的类型,即使有两个类的成员列表完全一样(在我们看来),但只要它们的名字不同,它们就是完全不同的类型。
- 我们可以使用 类名 定义类对象,也可以使用 class或struct+类名 来定义对象,二者完全等价。
类的声明
class Screen;
- 这种声明有时被称作 前向声明,它向程序引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型,也就是说我们已知Screen是一个类类型,但是我们不清楚它到底包含哪些成员。
- 不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
- 对于一个类,我们在创建它的对象之前必须定义它。因为如果只有类的声明,编译器无法了解这一的对象需要多少存储空间。
- 因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。但是一旦一个类的名字出现后,它就被认为是声明过了,因此类允许包含指向它自身类型的引用或指针。
3.4 友元再探
类之间的友元关系
class Test
{
friend class ClassName;
}
- 上文中在Test类中指定了一个友元类ClassName,则友元类ClassName的成员函数可以访问此类包括非公有成员在内的所有成员。
- 注意:友元不存在传递性。每个类负责控制自己的友元类或友元函数。
另成员函数作为友元
- 另ClassName类的clear函数称为Test类的友元
class Test
{
int clear(int i);
};
class ClassName
{
friend int Test::clear(int i);
}
- 需要注意类之间的依赖关系。
- 首先声明Test类,因为ClassName需要访问它的作用域。可以定义除clear以外的所有函数。
- 然后再声明ClassName类,因为clear函数需要访问它的作用域。
- 再然后定义ClassName类,包括对于claear的友元声明。
- 最后定义clear函数,此时它才可以访问ClassName的成员。
函数重载和友元
- 尽管重载函数的名字相同,但它们仍然是不同的函数。因此如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。
友元声明和作用域
- 友元声明的作用是影响访问权限而非普通意义上的声明,因此仅友元声明函数后不能使用这个函数,需要等到函数正式声明后才可使用。
四、类的作用域
4.0 简介
作用域和定义在类外部的成员
- 每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。不论哪种情况,跟在运算符后的名字都必须是对于类的成员。
- 在类的外部,成员的名字被隐藏起来了,一旦遇到了类名,定义的剩余部分就在类的作用域之内了。因此使用类内部定义的类型,比如类型别名等,都需要使用类名+域运算符。
- 示例代码:
class Test1
{
private:
typedef int pos;
pos test(pos i);
};
Test1::pos Test1::test(Test1::pos i)
{
return 0;
};
- 注意:对于函数名test加上类名和域运算符是为例说明test函数位于类作用域中。而pos是类内部定义的类型,因此也需要使用类加域名运算符进行访问。
- 注意:private仅修饰类中成员,表示外部对类中成员的访问权限。而如类中定义的类型,即使位于private作用范围类,外界也可以访问使用。
4.1 名字查找与类的作用域
- 名字查找的过程:
- 先在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
- 如果没找到,继续查找外层作用域。
- 如果最终没有找到匹配的声明,则程序报错。
- 类的定义分两步处理:
- 首先,编译成员的声明。
- 直到类全部可见后才编译函数体。
- 注意:编译器处理完类中的全部声明后才会处理成员函数的定义。
类型名要特殊处理
- 类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
- 如果类在成员函数中定义了与数据成员同名的名字,则在这个成员函数中使用这个名字会访问到新的声明或定义,如果想访问名字被覆盖的数据成员,可以显式的使用this指针来访问数据成员。
- 如果在类中要访问某个全局作用域中的名字,而这个名字在类作用域或成员函数的块作用域被覆盖了,那么使用 “::” 作用域访问符直接访问全局作用域 (当"::"前什么名称也没有时)。
- 如果对一个类进行了声明,然后在全局作用域中定义了一个方法。当我们在类内部定义类的成员函数时,在全局作用域定义的那个方法是不可见的,因为这个方法位于类的声明或定义之后。如果我们仅仅是对类进行了声明,然后定义全局作用域中的方法,然后再实现类中的成员函数,则此时成员函数的实现位于全局作用域方法之后,我们就可以使用它了。
- 总结:不要在头文件中定义类,要对类进行声明。不要在头文件中定义类的成员函数,要在.cpp文件中实现成员函数。实现成员函数时要注意和其他函数的先后顺序。先后顺序影响的是可见与不可见的问题。定义函数与声明函数的唯一区别在于声明函数没有函数体和花括号,而定义函数有花括号和函数体。
五、构造函数再探
5.1 构造函数初始化列表
- 如果我们不采用构造函数的初始化列表,而是将参数在构造函数体内部一一赋值给数据成员。这两者的区别在于初始化列表是对数据成员进行初始化操作,而"="本质是对数据成员进行赋值操作。这一区别的深层次影响完全取决于数据成员的类型。
构造函数的初始值有时必不可少
- 如果数据成员是const或引用的话,必须将其初始化,而无法对其进行赋值操作。
- 如果数据成员是类类型而且该类没有提供默认构造函数时,也必须对这个成员进行初始化。(因为C++中定义一个类对象时必须调用构造函数进行初始化,类似于常量一样,但不同的是类对象现在的问题是编译器无法给它调用构造函数,因此就无法申请这块内存空间)
- 我们初始化const和引用类型的成员数据的唯一机会就是通过构造函数初始值。
- 如果成员是const、引用或者某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
- 初始化数据成员和给数据成员赋值的效率也不同,前者直接初始化数据成员,后者先初始化再赋值。
成员初始化的顺序
- 要注意的是,构造函数中初始化列表的初始化顺序和初始化列表的排列顺序并不相同,构造函数中初始化列表初始化的顺序是按照数据成员在类中声明或定义的先后顺序来的。
- 因此,最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,避免使用某些数据成员初始化其他数据成员。
默认实参和构造函数
- 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
5.2 委托构造函数
委托构造函数
- C++11扩展了构造函数初始值的功能,支持了所谓的委托构造函数。
- 示例代码:
class Test
{
Test(int aT, double bT, std::string strT) :a(aT), b(bT), str(strT) {}
Test(int aT) :Test(aT, 0, "") {}
Test(int aT, double bT) :Test(aT, bT, "") {}
private:
int a;
double b;
std::string str;
};
5.3 默认构造函数的作用
- 默认初始化发生的场景:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型成员没有在构造函数初始化列表中显式的初始化时。
- 值初始化发生的场景:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
- 当我们不使用初始值定义一个局部变量时。
- 当我们通过书写形如< T >的表达式显示地请求值初始化时,其中T是类型名。例如vector对象可通过(n)的方式指定容器大小,以此对元素初始化器进行值初始化。
使用默认的构造函数
- 如果需要使用默认构造函数初始化一个对象,直接定义它会自动调用默认构造函数。但是不能在对象的后面加括号,这样表示的是定义一个函数。示例代码:
int main()
{
Test test;
Test test();
}
5.4 隐式的类类型转换
- C++在内置类型之间定义了几种自动转换规则。同样的,我们也能为类定义隐式转换规则。
- 如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换规则,有时我们把这种构造函数称作转换构造函数。
- 能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
- 示例代码:
class Test
{
Test(int aT, double bT, std::string strT) :a(aT), b(bT), str(strT) {}
Test(int aT) :Test(aT, 0, "") {}
Test(double bT) :Test(0, bT, "") {}
Test(std::string str) :Test(0, 0.0, str) {}
private:
int a;
double b;
std::string str;
void test()
{
Test t1 = 1;
Test t2 = 1.0;
std::string str = "hello";
Test t3 = str;
}
};
- 通过转换构造函数,我们可以定义转换为某个类的隐式转换规则。定义之后,在以后任何需要类类型的地方,我们都可以使用能自动转换为类类型的数据类型作为代替。
只允许一步类类型转换
- 编译器只会自动执行一步类型转换。
- 例如字符串字面值的实际类型是const char[n] 即字符数组类型,在需要字符串string类型的地方我们可以使用字符串字面值,因为字符串字面值可以自动的转换为string对象。那么根据上文代码,现在string对象可以转换为Test对象,那是不是字符串字面值就可以转换为Test对象了呢?
- 从字符串字面值到string对象需要一次转换,从string对象到Test对象又需要一次转换,因此这个理想的过程实际需要两次自动转换。因为编译器只会自动执行一步类型转换,因此字符串字面值无法转换为Test对象。这也是上文中我们给t3初始化为什么要定义一个str的原因。
- 错误示例:
Test t4 = "world";
类类型转换不是总有效
- 有些时候我们定义了构造函数ClassName(T),这导致编译器认为T类型可以自动转换为ClassName类型,当一个地方需要ClassName类型但却提供了一个T类型时,编译器会执行ClassName(T)构造函数将T类型转换为一个临时的ClassName类型对象。
- 但是我们可能并不想这样做,我们只希望能够使用T类型来初始化ClassName对象,而不希望T类型对象会自动转换为ClassName对象。
抑制构造函数定义的隐式转换
- 在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:
explicit Test(int aT) :Test(aT, 0, "") {}
explicit Test(double bT) :Test(0, bT, "") {}
explicit Test(std::string str) :Test(0, 0.0, str) {}
- 加上explicit后,构造函数定义的隐式类型转换被阻止。现在这种情况下,下列代码都是非法的:
Test t1 = 1;
Test t2 = 1.0;
std::string str = "hello";
Test t3 = str;
- 关键字explicit只对一个实参的构造函数有效。拥有多个实参的构造函数不能用于执行隐式转换,所以无需将explicit用于多实参构造函数上。
- 只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。
explicit
- 当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用,而不能使用"="形式的拷贝初始化。而且编译器将不会在自动转换过程中使用该函数。
为转换显式地使用构造函数
- 尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显示地强制进行转换。
class Test
{
Test(int aT, double bT, std::string strT) :a(aT), b(bT), str(strT) {}
explicit Test(int aT) :Test(aT, 0, "") {}
explicit Test(double bT) :Test(0, bT, "") {}
explicit Test(std::string str) :Test(0, 0.0, str) {}
private:
int a;
double b;
std::string str;
void test()
{
Test t1 = Test(1); t t2 = static_cast<Test>(1.0);
std::string str = "hello";
Test t3 = Test(str);
}
};
- 标准库中接受一个单参数的const char*的string构造函数,不是explicit的。
- 标准库中接受一个容量参数的vector构造函数是explicit的。
5. 5 聚合类
- 聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有virtual函数。
- 示例代码:
#include "Sales_data.h"
using std::cin;
using std::cout;
using std::endl;
using std::vector;
using std::string;
struct Data
{
int ival;
string s;
double b;
};
int main()
{
Data vall = { 10,"Hello",2.0 };
return 0;
}
显式初始化成员的优缺点
- 我们可以使用花括号括起来的成员初始化列表初始化聚合类的数据成员。
- 显示地初始化类的对象的成员存在三个明显的缺点:
- 第一,要求类的所有成员都是public的。
- 第二,将正确初始化每个对象的每个成员的任务交给了类的用户(而非类的作者)。所以这样的初始化过程冗长乏味且容易出错。
- 第三,添加或者删除一个类成员后,所有初始化语句都需要更新。
5.6 字面值常量类
- 类也可以是字面值类型。
- 字面值类型的类可能含有constexpr函数成员,非字面值类不能含有constexpr函数成员。
- 字面值类中的constexpr函数成员都必须符合constexpr函数的所有要求,它们是隐式const的。
- 数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,当它符合下列要求时,它是一个字面值常量类:
- 数据成员都必须是字面值类型
- 类必须至少含有一个constexpr构造函数
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式。或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
constexpr构造函数
- 尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。
- constexpr函数可以声明成"=default"的形式 (或者是删除函数的形式)。否则,constexpr构造函数就必须既符合构造函数的要求,又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr构造函数体一般来说应该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了。
- 示例代码:
class Test
{
constexpr Test(int aT, double bT) :a(aT), b(bT){}
private:
int a;
double b;
void test()
{
constexpr Test te(1, 1.0);
}
};
7.6 类的静态成员
声明静态成员
- 通过在成员的声明之前加上关键字static使得其与类关联在一起。静态成员可以是public或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。
- 类的静态成员函数不与任何对象绑定在一起,它们不包含this指针。作为结果,类的静态成员函数不能声明为const的,我们也不能在static函数体内使用this指针。这一限制既适用于this的显式使用,也对调用非静态成员的隐式使用有效。
使用类的静态成员
- 我们使用作用域运算符直接访问静态成员,也可以使用类的对象、引用和指针来访问静态成员,类的成员函数不需要通过作用域运算符就能直接使用静态成员。
class Test
{
private:
int a;
double b;
static string name;
void test()
{
Test te;
Test::name;
te.name;
name;
}
};
定义静态成员
- 当在类的外部定义静态成员时,不能重复static关键字,该关键字仅出现在类内部。
- 必须在类的外部定义和初始化每个静态成员,和其他对象一样,一个静态数据成员只能被定义一次。
- 示例代码:
class Account
{
public:
void calculate() { amount += amount * interestRate;}
private:
static double interestRate;
double initRate();
}
double Account::interestRate = initRate();
- 上述代码在类Account的作用域中定义了静态变量interestRate。
- 注意,从类名Account开始,这条定义语句的剩余部分就都位于类的作用域之内了。
- 要想确保对象只定义一次,最好的办法就是把静态数据成员的定义和其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
- 即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
静态成员的特定
- 静态成员可以是不完全类型,即类中的静态成员的数据类型可以是类本身。
- 静态成员可以作为函数的默认参数。