What & How & Why

C++ Primer 笔记 第七章


在C++中,我们用类来创建自定义类型。

类的主要概念有两个:数据抽象(data abstraction封装(encapsulation

数据抽象又主要分为两个部分:

  • interface(接口):为类的使用者提供操作功能
  • implementation(实现):类设计者用于定义类的内容。该部分内容用户不能访问;其中包括类的数据成员和操作这些成员的,非一般性使用的函数等等。

数据抽象和封装定义了抽象数据类型(abstract data type)。在这种类型中,设计者只用关心如何实现类,而使用者则只用关心如何操作类即可。

定义抽象数据类型

首先我们来看看以前我们定义的 Sales_data 自定义类型:

struct Sales_data {
    std::string book_no;
    unsigned units_sold = 0;
    double revenue = 0.0;
}
这是一个自定义的类型;但不是一个抽象数据类型。这个类型里所有的数据成员大家都可以访问,并没有 interface 和 implementation 的区别。相比之下书中第一章使用的 Sale_items 类就是一个抽象数据类型。我们并不知道类里有什么数据类型,但我们仍然通过设计者提供的函数来完成了对类的使用。这样的数据类型,才叫抽象数据类型。

定义成员函数

  • 类成员函数,属于 implantation,必须在类中声明,但可以定义在类的外部。
  • interface 相关的函数,比如一些操作函数等,定义声明都放置于类外部。

定义成员函数的写法与普通函数一致:

std::string isbn() const { return book_no;};

this指针

成员运算符是如何访问成员函数的?

默认情况下,任意对类成员的直接使用,会引用 this 指向的对象。以下面代码为例:

total.isbn();
实际上该语句完成了三步操作:

  1. 使用成员访问符,获取了当前对象 total
  2. 编译器(隐式的)初始化了名为 this 的 parameter,保存了 total 的地址。
  3. 调用成员函数的时候,编译器将 this 以被调用的形式,将当前对象传递给了成员函数。

整个调用过程实际上可以写成:

Sales_data::isbn(&total);//&total is a argument that will pass to 'this' pointer
this 实际上指代什么?如何利用?

很显然,this 是一个指针,指向当前调用成员函数的类对象。因此 this 可以像下例一样利用:
(*this).bookno;
this -> bookno;//equivalent to the first one
那么先前我们定义的函数 isbn() 实际上就可以写成:
Sales_data::isbn() const {return this->book_no};
this 是什么样的指针?

由于 this 的本意就是代表当前调用成员函数的对象,因此 this 是一个常量指针(top-const pointer)。

const 成员函数

成员函数前加 const 的意义?

目的是不希望通过该成员函数修改调用的对象。

是怎么实现的?

Sales_data::isbn() const {return this->book_no};
直接在成员函数后加 const 即可。需要注意的是,为了实现上述的效果,这里的 this 指针隐式的完成了从 non-constlow-const 的转变。

为什么会有这样的转变?

  • 成员函数实际上是通过 this 指针来访问对象。
  • this 指针是一个 const pointer(top const),在作为 initailizer 的时候,会转换成 non-const 的指针(值传递)。
  • 如果希望成员函数不能改变调用的对象,this 指针需要可以指向 const object,需要从 non-const 转换 low-const(pointer to const)
  • 正常情况下应该直接将 this 指针的类型改变为 const Sales_data* const
  • 由于 this 不可见,因此 C++ 规定,只要在成员函数的 Parameter List 后加上 const 关键字的,都会为 this 加上 low-const 属性。

我们将此类的函数称为常量成员函数Const member functions)。该类函数隐含的意思是:函数调用的对象不会发生变化,且函数也无法修改调用的对象。

谁可以调用常量成员函数?

  • const 对象
  • pointer / reference to const
类的 scope 和成员函数

编译器处理类按照以下顺序处理:

  • 优先编译成员的声明
  • 其次才是函数体的处理

因此在类中,成员函数对成员的使用不受成员声明位置的影响

在类外部定义函数

在类外部定义的成员函数需要满足两个条件:

  • 函数的声明与类中必须完全一致
  • 类外的函数声明前需要注明来自哪个类

我们通过 类名 + scope operator 来注明来源类,比如:

double Sale_data::avg_price const {
    if (units_sold) {
        return revenue / units_sold;
    } else {
        return 0;
    }
scope operator 意味着将之后的函数所在 scope 都转到了类中。

定义返回当前对象的函数

如果需要返回当前对象,则可以使用下列语句:

return *this;
实例中的逻辑:当前类的内容 += 新的内容则可以实现为下面的形式:
Sales_data& Sales_data::combine(const Sales_data &rhs) {
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this; //return the object on which the function was called.
}
再以
total.combine(trans);
的形式调用。注意这里的返回类型是 Sale_data&

定义类相关非成员函数

什么函数可以归类到非成员函数(nomember functions?)

如果函数在概念上属于类,但不会在类中声明与定义,那这部分函数可以算作非成员函数。非成员函数属于 interface 的一部分,通常用于定义对类的操作。

非成员函数应该在哪里声明?

非成员的函数应该在其对应类所在 header 进行声明

使用 i/ostream 类来定义 read 和 print 函数

I/O类提供了 istreamostream 两种类型来让我们自定 readprint 的函数:

istream &read(istream &is, Sales_data &item)
{
	double price = 0;
	is >> item.bookNo >> item.units_sold >> price;
	item.revenue = price * item.units_sold;
	return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
	os << item.isbn() << " " << item.units_sold << " "
	   << item.revenue << " " << item.avg_price();
	return os;
}
几个注意事项:

  • iostream 类 is / os 都是不可拷贝的,因此必须使用引用来初始化对象。
  • 使用iostream 类进行读写的时候一定会造成对对象的修改,因此初始化的时候使用普通引用
  • 定义 read 的时候,因为对 items 中的 revenue 成员进行了写操作,因此 Sales_data 通过普通引用传递。
  • print 函数没有使用 endl 打印 newline。通常打印类函数只会做最小化的格式输出,以免影响到用户的自定义格式。
定义 Add 函数

add 函数不修改两个对象,但需要得到两个对象的和,因此我们使用一个临时对象存储结果。由于存在临时对象,因此需要按值传递的方式返回:

Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {
	Sales_data sum = lhs; // copy data members from lhs into sum
	sum.combine(rhs); // add data members from rhs into sum
	return sum; 
}

构造函数

构造函数类用来控制对象初始化的一类特殊的函数。任何时候类对象被创建,都会用构造函数来初始化。构造函数有几个特点:

  • 构造函数的名字与类相同
  • 构造函数没有返回类型
  • 构造函数可以有多个,类似于函数的重载:根据函数参数的类型和数量来区分不同的初始化过程。
  • 构造函数不能被声明为 const : const 这个属性要激活,必须经过构造函数的初始化。
合成默认构造函数

如果我们没有显式的给指定类定义构造函数,那么编译器自己会定义一个构造函数给类。我们称这个构造函数为:合成的默认构造函数(Synthesized Default Constructor),类似于重载函数,构造函数可以有多个。

默认构造函数初始化类对象的策略是?

默认构造函数按下面的策略初始化类成员:

  1. 如果类成员有任何初始值,使用该值初始化成员
  2. 如果没有,则对该成员进行默认初始化
默认构造函数可能无法满足某些类的要求
  1. 需要多个构造函数的情况下:在没有用户定义的构造函数时,编译器才会提供构造函数。
  2. 默认构造函数可能导致初始化出现问题,比如没有初始化的 Bulid-in type,交予默认构造函数则是 undefined。
  3. 默认构造函数无法为某些复杂类初始化:比如类中有类成员,而这个子类没有默认构造函数,那么编译器就无法初始化。
构造函数的定义

下列定义了 4 个构造函数:

Sales_data() = default; //ask compiler to generate the constructor for us
Sales_data(string& s, unsigned n, double p) : book_no(s), units_sold(n), revenue(p*n) { }
Sales_data(string& s, unsigned n, double p) : book_no(s) { }
Sales_data(std::istream& is);
第一个带 default 的构造函数是什么意思?
Sales_data() = default;
这样的写法等同于手动为当前类提供一个合成默认函数= default 是 C++ 11 新写法,用于告知编译器我们需要一个默认行为的构造函数。该语句可以在任意地方写,如果放置到类中书写,则该合成默认函数为 inline 函数。

使用默认构造函数的前提是所有类成员完成初始化。某些编译器可能不支持类成员的初始化,这种情况下必须使构造函数初始化列表来进行手动初始化。

自定义构造函数应该怎么写?

例子中的第二个,第三个构造函数均为正常的自定义构造函数。我们使用构造函数初始化列表来进行初始化:

Sales_data(const std::string s, unsigned n, double p) : 
			book_no &s, unit_sold(n), revenue(p*n) {}
构造函数的 Parameter 定义了一些用于初始化类成员的变量;而冒号后的列表则被称为初始化列表,用于初始化类成员。

还有一种写法如第三个构造函数:
Sales_data(string& s, unsigned n, double p) : book_no(s) { }
这种情况下,其他未被构造函数初始化的类成员会以默认构造函数初始化成员的方式进行初始化。

构造函数后的函数体为什么是空的?

函数体为构造函数提供额外的功能。如果类成员只需要初始化列表就能完成初始化,那么函数体就会留空。
Sales_data::Sales_data(std::istream& is) {
     //read will get a stream from is and give it to which object is calling function.
    read(is, *this);
}

在类外定义构造函数

与外部定义的成员函数一样,外部定义的构造函数需要加上类的作用域:

Sales_data::Sales_data(std::istream& is) {
	read(is, *this); //read will read a transaction from is into the oject
}
上面的函数是什么意思?

首x先,该函数只接收了一个 istream 类型参数 is,意味着该构造函数试图从输入的内容中获取对类成员的初始值。为了配合这个意图,函数体重调用了 read(is, *this)。根据 read 函数的功能,该构造函数实现的功能是使用输入信息对当前对象进行初始化,实际上等同于:
Sales_data item1;
double price = 0;
std::cin >> item1.book_no >> item1.units_sold >> price;
为什么 this 要解引用?

read(is, *this)这个语句中,因为 read 的参数必须都是引用,我们不能直接传一个指针。所以我们对 this 指针进行了解引用,这样我们就可以把 is 作为对象进行传递了。

拷贝,复制,析构

C++ 中,编译器不仅支持类的默认初始化,同时也控制类与对象的拷贝,赋值以及析构(Destory)。如果用户没有定义这些行为,编译器会为我们做默认的合成,比如:

total = trans;
实际上编译器帮你做的是:
total.bookNo = trans.bookNo;
total.unit_sold = trans.unit_sold;
total.revenue = trans.revenue;

某些类无法依赖默认合成的版本

某些情况下,默认合成的版本是无法正确的工作的:比如管理动态内存。当然,如果希望编译器进行有效的动态内存管理,可以使用 Vector 和 String。以上三种操作这两种标准库类型都可以以默认的形式正确运行。

访问控制 / 封装

如何实现封装(Encapsulation)?

为了实现封装,C++ 提供了关键 Public / Private 来对访问进行控制:

  • public specifier 定义的成员可以被整个程序访问;他们都属于 interface 的一部分,比如构造函数,成员函数等。
  • private specifier 后定义的成员只能由成员函数访问;这一部分对类的实现做出了封装,比如类实现函数,类成员等。

Specifier 有哪些特点?

  • specifier 的数量不受限置
  • specifier 的作用范围是从当前 specifier 到下一个 specifier 之间

Class & Struct

Class 与 Struct 有什么不同?

两者的主要区别在于如何定义成员函数:

  • struct 中,默认成员都是 public 成员。
  • class 中,默认成员都是 private 成员。

友元 Friend

私有成员是不能被类外部的对象访问的。但我们知道有些非成员函数其实是属于 interface,其实现可能需要访问类成员。我们可以在类里面用 friend 关键字使这些函数获得访问类成员的权限,比如:

friend Sales_data add(const Sales_data&, const Sales_data&);
需要注意的是,友元的声明必须在类里。当然出现的位置是随意的,不过一般都把友元的声明集体的放到类的开头。

友元函数的声明

友元函数的声明不能代替正经的函数声明。它的作用只在于指定访问权限。如果用户需要调用友元函数,那么我们还需要对被友元的函数重新声明一次。

通常的做法是在 class 被定义的头文件 对友元函数分开提供一般性的声明。

类的其他特性

类成员的特性

本章范例:

  • Screen 类
    • string: 存放 screen 的内容
    • string::size_type * 3: 存放光标位置、高和宽。
如何在类中使用 type alias 简化类型

class Screen {
public:
    typedef std::string::size_type pos; //typedef
    using pos2 = std::string::size_type; //using
private:
    statements...;
}
为什么使用 type alias?

  • 简化类型
  • 将简化的类型交给用户使用,隐藏真正的类型,实现类型的封装

如何使用 type alias 进行封装?

将用户使用的类型在 public 中进行 alias 即可:

public:
		typedef std::string::size_type pos; 
		using pos = std::string::size_type;
type alias 的注意事项?

与一般类成员不同,定义类型的类成员需要在使用前可见。因此,type members 一般都放到类的开头

inline成员函数

之前提到过,所有类中定义的成员函数都会自动转化为 inline 函数。因此,类中定义的函数之前的 inline 关键字是可以省略的。利用这个性质,可以只对外部定义成员函数添加 inline关键字,从而达到提高代码可读性的效果:

inline Screen &Screen::move(pos r, pos c) {
    statements;
}
书上提出了两点建议:

  1. inline 关键字只用在类外定义函数的地方;这样的写法有助于代码的可读性。
  2. inline 函数最好和对应他的类定义在同一个 header 里。
成员函数的重载

成员函数也可以进行重载,重载规则与一般函数一致:

char get() const { return contents[cursor]; }
char get(pos ht, pos wd) const;

Mutable数据成员

Mutable 关键字的作用?

使用 Mutable 关键字可以使目标获得被修改的权限。被 mutable 修饰以后,被修饰的对象将永远可以被修改,即使:

  • 对象自身是 const
  • 对象自身是 const 对象的的一部分
  • 修改该对象的函数是 const

Mutable 的应用场景?

  • 在一个 const 对象中,你希望只有一小部分成员可以被修改
  • 你希望某个对象可以被 const 函数修改

比如下面的例子:

class SomeClass {
public: 
    void some_func() const;
private:
    mutable size_t ctr; // can change even if in a const object;
}
void SomeClass::some_func() {
    ctr++;
}

类数据成员的初始化

书上希望使用 Window_mgr 类来管理多个屏幕,于是选择了 Screen 类型的 vector 作为管理容器。如果希望给这个 vector 一个初始值,在 C++ 标准中最好的办法是进行构造函数的列表初始化:

class Window_mgr {
private:
std::vector<screen> screens {Screen(24, 80, ' ') };
}
这里使用了构造函数为 vector 的第一个元素进行了初始化,等于创建了一个空白屏幕。因此可知,在为在类中对成员初始化时,可以由以下两种方式实现:

  1. 通过 “ = ” 进行初始化
  2. 通过 “ { } ” 进行初始化

返回 *this 的成员函数

返回值为 *this 表达式的成员函数,代表了该函数打算返回当前成员函数所在的类对象。通常情况下,类对象的返回以引用的形式返回。*this 会得到当前类对象,如果返回类型是类的引用则返回引用,否则则以复制的形式返回对象。

返回引用有什么额外的好处?

除了提高程序的性能以外,由于引用返回值是一个左值,利用该性质我们可以对对象进行连续的调用:

Screen myScreen;
myScreen.move(4,0).set('*');

Const 成员函数返回 *this

假设我们新建一个成员函数 display(),该函数的作用是打印出 Screen 对象的类容。由于打印不涉及修改 Screen,因此该函数应该被设置为一个常量成员函数。但是,常量成员函数会自动的将 *this,也就是这里的 Screen 类改变为 low-const, 因此 display 的返回值实际上是:

const Screen&;
由于 reference to const 是不能作为左值的,因此下面的调用是有问题的:
Screen myScreen;
myScreen.move(4,0).display(cout).set('*'); //error, display returns a const Screen&

使用重载解决上述的问题

为什么要考虑 const 的重载情况?

之前的例子实际上说明了一种应用,即同时存在 non-constlow-const 的对象需要传递;在很多情况下我们希望保证对象的 constness 的一致性,也就函数处理不会改变对象的 constness。

由于常量成员函数会隐性的将 *this 转换为 low-const 的版本(比如上例中的 display()),导致 Non-const 的对象被其处理之后也会以 low-const 的形式返回(也就是所谓的 const chain)。为了保证常量成员函数不会将 non-const 的对象以 low-const 对象的形式返回,使用重载区分 const / non-const 的对象是非常必要的。

如何实现 const 对象的分离处理?

函数的重载有一个性质:non-const 的对象调用重载函数,会优先选择 non-const 的函数版本。根据这个思路来设计:

  1. 一个功能实现函数负责实现功能,比如下例的 do_display()
  2. 两个成员函数负责控制返回值的 const 属性,通过编译器让 non-const 的函数自动处理 non-const 版本的对象

class Screen {
public:
    Screen &display(std::ostream &os) 
            { do_display(os); return *this; }
    const Screen &display(std::ostream &os) const
            { do_display(os); return *this; }
private:
    void do_display(std::ostream &os) const { os << contents; }
这段代码是如何进行 const 对象的筛选的?

  1. 首先,两个函数都会调用 do_display() 完成具体的功能。执行的过程中,这两个函数的 this 指针都会传递给 do_display()
  2. 其次,non-const 版本的 display() 传递给 do_dislay()this 指针会由 pointer to non-const 转换为 pointer to const,也就是在 do_display() 中,该 this 指针无法对对应的对象进行修改。
  3. 再次,在返回的时候,两个函数都通过 *this 对当前对象解引用,并且返回。由于 non-const 版本中,即便 this 指针不能修改对应对象,但该对象的 constness 并没有发生改变。因此以解引用形式 *this 返回的依然是 non-const 的对象。
  4. 最后,使用重载函数区分 const 的特性,就根据返回对象的 constness 自动选择对应的重载版本了。

以 *this 解引用形式返回对象是使用重载保证 constness 的关键。

Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // calls non const version
blank.display(cout); // calls const version
这样的设计有什么好处?

  1. 复用性:避免在不同的地方写重复的代码
  2. 可读性:随着函数的复杂度提高,这么写会更加让人易懂
  3. debug 更方便:如果功能出现问题,只对核心函数 do_display() 排查 bug 就可以
  4. 避免额外开销:do_display() 是 inline 函数。

总而言之,设计良好的 C++ 程序更家倾向于拥有多而小的功能实现函数。需要使用的时候,再通过一系列的其他函数对其调用。

Class Types

对于类来说,每个类都定义了一个不同的类型。两个类即使成员完全一样,他们也是不同的类型。

类的提前声明

什么是类的提前声明?

与函数相同,没有类定义的声明被称为类的提前声明Forward declaration)。样的声明表明我们的类是一种不完全类型Incomplete type)。

类的提前声明的应用场景?

  1. 用于定义指向该类的指针、引用。
  2. 声明函数的时候,将类类型作为函数的返回值类型:

class link_screen {
    link_screen *prev;
    link_screen *next;
}
什么时候类必须被定义?

  • 创建对象之前,类必须被定义:编译器需要知道该类对内存空间的要求。
  • 使用该类的指针 / 应用之前,类必须被定义。

由于上述的要求,类的对象是不能作为该类的成员的。

友元函数再探

类的友元

与函数相同,类也可以作为友元的对象。

什么情况下需要友元类?

当我们需要控制类,即使用另外一个类对目标类的私有成员进行操作的时候。比如书中的例子,使用 Windows_mgrScreen 进行屏幕清空操作,就需要访问 Screen 类的私有成员 std::string contents

友元类在哪里声明?声明方式是?

与友元函数相同,友元类的声明在类定义的开头:

class Screen {
    friend class Window_mgr; // define Window_mgr as a friend of Screen
}
Windows_mgr 声明为友元类之后,就可以使用其 clear 成员函数清空 Screen::contents 的内容了:
class Window_msr {
public: 
	//screen ID
	using screen_index = std::vector<Screen>::size_type;

	//reset screen at given position
	void clear(screen_index i);
private:
	std::vector<Screen> screens{Screen(24, 80, ' ')};
}

void
Window_msr::clear {screen_index i} {
	Screen &s = screens[i];
	s.contents = string(s.height * s.width, ' ');
}

友元关系并不能传递。因此如果目标类有友元类,其友元类的友元类无法访问目标类的私有成员(你朋友的朋友并不是是你的朋友)

类成员函数的友元

不仅类可以作为友元的对象,类的成员函数也可以单独作为友元的对象。比如上例中,单独对 clear 函数进行友元:

class Screen {
    friend void Window_mgr::clear(ScreenIndex);
}
声明顺序对类成员函数进行友元有什么影响?

友元类成员函数时,需要仔细考虑对应实体的声明 / 定义依赖关系。一个总的原则是,如果某个定义需要其他类型的声明,那么该声明一定要提前完成,比如上例:

  • 首先第一步,声明 Screen 类:

因为接下来 Window_mgr 的定义需要用到 Screen 类的类型。

  • 接下来定义 Window_mgr 类:

Window_mgr 类中有两处使用到了 Screen 类的类型:

using screen_index = std::vector<Screen>::size_type;
std::vector<Screen> screens{Screen(24, 80, ' ')};
如果这里没有 Screen 类的提前声明, Window_mgr 无法确定 Screen 到底是什么。

  • 声明但不定义 Window_mgr::clear()

不定义 clear() 函数是因为该函数需要操作Screen 类的类成员 Screen::contents。在 Screen 类没有定义之前,该成员是无效的。

  • 定义 Screen,并完成 Window_mgr::clear() 对其的友元声明:

如果没有对 clear() 进行提前声明,那么该友元声明也是无法完成的。

  • 最后,定义 Window_mgr::clear()

此时 clear() 定义所需的所有条件均满足。

Ref: Member function a friend

函数的重载和友元

重载过的函数尽管名字相同,但却是两个不同的函数。因此,每一个需要友元的重载版本都需要进行对应的友元声明

友元函数和 scope

友元函数声明的要求是什么?

友元函数的声明要求在类外必须要有该函数对应的一般声明。

为什么这么要求?

归根结底还是函数的作用域问题。由于友元函数的声明只能保证访问权而没有创建作用域的功能,因此这种声明只能作出一种假定:我假定该函数作用域已经存在了;也就是说,友元函数声明的时候,默认该函数已经声明过了。

由于友元函数声明在类中的位置,为了确保该函数的作用域可见,最好的办法就是在类外先声明该函数。

类的 scope

每个类都有自己的 socpe.如果要从外部访问类内部数据,通常有两种方法:

  1. 通过指针,引用或者对象来进行访问(操作符使用 “→”):
  2. 通过 scope 操作符(“::”)直接访问。

但是无论哪种方法,访问者都需要有访问权限,比如:

Screen::pos ht = 24, wd = 80; //using pos to define screen.
Screen scr(ht, wd, ' ');//pos type defined by the first line.
Screen *p = &scr; // p point to a object scr;
char c = scr.get(); //using object to access member function get();
p -> get(); // using pointer to access member function.

类的 Scope 与外部定义的成员

如果想在外部定义类成员,必须指定类的 scope。这点可以通过 类名 + scope 操作符实现:

void Window_mgr::clear(ScreenIndex i) {
    statements....
}
外部定义的成员函数的返回类型是如何处理的?

默认情况下,外部定义的函数返回类型并没有处于类的 scope下。如果返回类型属于类成员,那么必须给该返回类型也指定类的 scope。比如下面的函数需要返回一个类成员类型:

//declaration in the class
screen_index add_scrren(const Screen&);

//definition outside class
Window_msr::screen_index
Window_msr::add_screen(const Screen& scr) {
	screens.pushback(scr);
	return screens.size() - 1;
}

名字查找和类作用域

名字查找Name lookup)指是匹配 name 与其对应声明的过程。常规的名字查找策略如下:

  1. 首先查找当前 block 是否有对应 name 的声明,只有 name 没有使用过的声明才有效
  2. 如果没有结果,查找外层 scope(s)。
  3. 若最终还是没有找到,则程序返回错误。

但对于类成员函数中使用的变量名,编译器有不同的策略:

  1. 首先处理所有类成员的名字声明(假设成员函数会使用类中的名字)
  2. 类可见后,才会处理该函数的定义部分。

为什么有成员函数的例外?

为了方便成员函数的定义。如果按照一般声明可见后才可以使用类型的规则,那么函数中的变量名只能用已经可见的哪些。

该处理策略应用的范围?

只能应用于成员函数内部中出现的 name。成员函数自身的返回值类型parameter 类型 需要遵循一般的 name lookup 规则,也就是使用该类型之前必须声明该类型。

用于类成员的名字查找

对于类层级的名字(类成员的名字,成员函数的 parameter 和 返回值名),遵循一般的规则:

  1. 使用前声必须可见:类型的声明必须先于使用
  2. 由内往外找:如果在类定义中找不到该类型的声明,那么编译器会转到该类被定义的作用域里去找

比如下面的例子:

typedef double Money;
string bal;
class Account {
public:
    Money balance() { return bal; }
private:
    Money bal;
根据之前的规则:

  • 由于 Money 名字是返回类型,因此需要遵循一般的 name lookup 三步原则:
    1. 查找当前 scope 中,名字首次出现之前的部分,这里的 scope 是类 Account,没有找到 Money
    2. 继续查找外层的 scope,此时查找到 Money 的声明。
  • bal 变量,不属于返回类型和参数列表类型,则遵循的是类成员的两步查找规则:
    1. 首先查找整个类,查找到了 bal 的声明
    2. 由于编译器会先处理类的声明使其可见,再处理成员函数的定义,因此 blance() 函数中, bal 的类型是 Money

这里看出明显的区别。遵循一般名字查找规则的名字只会搜索当前 scope 中名字第一次可见之前的区域;而满足要求的类中名字,则会搜索整个类的 scope。

Type Names 需要特殊处理

需要注意的是,不同于一般名字的特性,C++ 不允许经过 type alias 的名字在内部和外部 scope 同时使用。比如下例的 Money 类型,由于外部已经有定义了,因此不允许在类中再定义,即便是同类型也不可以:

typedef double Money;
string bal;
class Account {
public:
    Money balance() { return bal; } //using outter type name Money
private:
    Money bal;
    typedef double Money; //error, redefined

一般来说,type alias 的声明应该放到类的起始部位,这样接下来的声明都可以使用。

成员函数定义中的名字查找
  1. 首先查找该函数函数体中的声明。该声明查找的区域只限于名字第一次出现之前的区域
  2. 如果没有查找到该名字,会返回到类中查找;此时查找的区域是整个类
  3. 还没有找到,去类scope 的上一级 scope 查找,查找的范围为成员函数定义的位置之前

注意:请不要类成员成员函数内的变量指定相同的名字

下面这个问题在 习题7.30 中也出现过。该例子很好的说明了成员函数体内部的名字的查找方式:

int height; // defines a name subsequently used inside Screen
class Screen {
public:
	typedef std::string::size_type pos;
	void dummy_fcn(pos height) {
	cursor = width * height; // which height? the parameter
}
private:
	pos cursor = 0;
	pos height = 0, width = 0;
};
需要注意的是,因为 dummy_fcn() 的 parameter height 的 scope 与其相同,因此参与函数定义的 height 并不是指的类成员,也不是指的外部定义的成员,而是 parameter height。如果这里要使用类成员 height,那么需要显式的使用 this 指针,或者加上类的作用域
cursor = width * this->height;  
cursor = width * Screen::height; //equivalent way to indicate the member 'height'
此时最外层的 height 变量被类成员 height 隐藏了。如需使用最外层的 height,需要显式的加上全局作用域
cursor = width * ::height; //global 'height'

成员函数在外部有定义时的名字查找

之前在类中的 name look up 规则依然适用于外部定义的成员函数。成员函数在外部进行定义的 scope 会被当做函数体内部的 scope 处理

int height; // defines a name subsequently used inside Screen
class Screen {
public:
	typedef std::string::size_type pos;
	void setHeight(pos);
	pos height = 0; // hides the declaration of height in the outer scope
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var) {
// var: refers to the parameter
// height: refers to the class member
// verify: refers to the global function
	height = verify(var);
}
除此之外,第三条中 “查找的范围为成员函数定义的位置之前” 可以解释这里 verify 名字的合法性问题:

  1. verfiy 名字处于函数体内部,但第一轮查找中,verify 之前的区域并没有声明
  2. 此时跳到类 Screen 中查找,查找完类中所有名字,也没有发现声明
  3. 最后到外层 scope 查找。由于 verfiy 的声明处于 setHeight() 的定义之前,因此找到了 verify
稍微总结一下
  • 三种查找
    • 普通名字查找
    • 类中的名字查找
    • 成员函数定义中的名字查找

Ref:Unqualified name lookup

再谈构造函数

构造函数的初始化列表

如果不使用构造函数的初始化列表,也能进行函数的初始化:

Sales_data::Sales_data(const string &s) { //default initialized here
    bookNo = s;
}
为什么推荐使用初始化列表?

严格的说来,该方法并不是初始化,而是先定义了 bookNo, 再给 bookNo 赋值。相对于真正的初始化,这样的方法有两个问题:

  • 不支持某些必须初始化的类型(比如引用,const 常量,成员没有默认值的类)
  • 赋值过程会带来额外的复制开销

由此看来,使用初始化列表是非常有必要的。

成员初始化顺序

类成员初始化的顺序与构造函数初始化列表中的顺序无关,只与类成员在类中出现的位置有关

初始化顺序会带来什么问题吗?

如果某个初始化的过程与初始化的顺序有关,那么很可能会带来问题:

class X {
    int i;
    int j;
public:
    X(int val): j(val), i(j) { } //undefined, i is initialized before j
上面的过程,本意是想让 j 先被初始化。但根据类中成员的顺序,i 需要先初始化,而此时 j并没有初始化。因此用 j 去初始化 i 会导致 undefined.

有什么更好的解决方案?

  • 避免使用类成员作为其他类成员的初始值:

X(int val): i(val), j(val) { };

  • 统一类成员的位置和构造函数初始化的顺序
Default Arguments and Constructors

如果一个构造函数使用其默认初始值初始化所有成员,那么该构造函数即为默认构造函数。

委托构造函数

委托构造函数(Delegating Construtor)是 C++11 中新增的,提高代码复用性的写法。如果几个构造函数拥有重复性的功能,那么就可以以委托的方式调用某个函数的功能:

class Sales_data {
public:
// nondelegating constructor initializes members from corresponding arguments
	Sales_data(std::string s, unsigned cnt, double price):
				bookNo(s), units_sold(cnt), revenue(cnt*price) {}
// remaining constructors all delegate to another constructor
	Sales_data(): Sales_data("", 0, 0) {}
	Sales_data(std::string s): Sales_data(s, 0,0) {}
	Sales_data(std::istream &is): Sales_data() { read(is, *this); }
// other members as before
};

上例中有总共 4 个构造函数。实际上,所有的构造函数都通过委托第一个构造函数实现了对应的初始化功能:

  • Sales_data():默认构造函数,通过委托 argument 列表(“”, 0, 0)实现默认初始化
  • Sales_data(std::string s)::单参数构造函数,通过 argument 列表(s, 0, 0)实现初始化
  • Sales_data(std::istream &is):多重委托,istream 函数→ 默认构造函数 → 3个参数的函数

注意多重委托的例子:

  • 这里的函数体是 istream 函数的。
  • 执行的顺序永远都是从被委托函数开始,从初始化列表到函数体依次执行。委托函数的函数体会在最后执行。

委托函数提供的 arugment 列表必须与被委托函数的 parameter list 匹配。

默认构造函数的角色

为什么必须要有默认构造函数?

因为在默认初始化或者值初始化的时候,必须要使用默认构造函数来处理。

默认初始化的场合:

  • 定义非静态成员 / 数组时不给出初始值
  • 类自身拥有使用默认合成构造函数初始化的成员
  • 类类型的成员没有显式的使用构造函数初始化列表进行初始化

值初始化的场合:

  • 数组初始化的时候,提供的初始值个数小于数组维度大小
  • 局部静态变量定义时,没有给定 initializer
  • 使用 Type() 的形式进行显式的值初始化,比如 vector<int> vi(10);

缺少默认构造函数将导致以上场合中的变量 / 对象无法进行初始化。

class NoDefault {
public:
    NoDefault(const std::string&);
    // additional members follow, but no other constructors
};
struct A {  // my_mem is public by default; see § 7.2 (p. 268)
    NoDefault my_mem;
};
A a;       //  error: cannot synthesize a constructor for A
struct B {
    B() {} //  error: no initializer for b_member
    NoDefault b_member;
};
上面的例子中,A 未为自己的类成员提供默认构造函数(违反默认初始化第二条),B 提供了,但没有显式的提供初始值(违反了默认初始化的第三条),因此都会初始化失败。

使用默认构造函数

默认构造的调用不能写成函数的形式

Sales_data obj(); //error, obj() is a function that returns an object of type 'Sales_data'
Sales_data obj; //ok, obj is a class object

类的隐式转换

类的隐式转换指的什么?

指在特定的条件下,会将某个 parameter 直接转换为对应的类类型。

类的隐式转换需要什么条件?

  • 接收单个 argument 的构造函数

string null_book = "99-99"
item.combine("null_book"); //a tempeory Sale_data object (99-99, 0, 0) has been created

  • 隐式转换只能发生一次

item.combine("99-99"); //error, "99-99" need to be convert to a string first
这种隐式转换的坏处?

转换过程中建立的临时类对象在相关操作完成之后会丢失掉,比如:
item.combine(std::cin);
该例中,输入的信息会保存到临时类对象中;但当 combine 的操作结束后,输入的信息就会随着临时类对象的消亡而丢失。

阻止这样的隐式转换

如果我们不想执行上述的隐式转换,我们可以用 explicit 关键字来申明构造函数:

explicit Sale_data(std::istream &is);
item.combine(cin); //error, istream constructor is explicit
几个注意点:

  • explicit 关键字只对有单个 parameter 的构造函数有效;具有多个 parameter 的构造函数没有隐式转换。
  • explicit 构造函数只能用于直接初始化(带赋值操作符的初始化都不能使用
  • 对于标准库中的类:
    • string 的构造函数不是 explicit
    • vector 的构造函数是 explicit 的(单个 size)。

同时,通过手动指定转换类型的方式也能达到 explicit 的效果:

item.combine(Sales_data("99-99")); //using constructor as an argument
item.combine(static_cast<Sales_data>(std::cin)); //explicitly convert the parameter to the class type

聚合类(aggregate class)

聚合类是一种用户可以直接访问成员的形式。他的特点是:

  • 所有成员都是 public 的。
  • 没有定义任何构造函数。
  • 没有类初始值。
  • 没有基类,也没有虚函数。

下面就是一个聚合类的例子:

struct Data {
    int ival;
    string s;
};
对这样的类我们可以使用列表初始化:
Data vall = { 0, "Anna" };
几个要点:

  • 列表中的初始值顺序要与聚合类中变量顺序一致。
  • 如果初始化列表中初始值小于类需要的值,那么靠后的成员被值初始化
  • 初始化列表的元素不能超过类成员数量。

字面值常量类

什么是 Literal Class?

  • 类成员都是 Literal Type 的聚合类,是字面值常量类Literal Classes)。
  • 不是聚合类的类,要归纳入 literal class 必须满足以下条件:
    • 数据成员都是 literal type
    • 类必须至少包含一个 constexpr 构造函数。
    • 如果一个成员有 In-class initializer:
      • 如果该成员是 build-in type,其对应的 in-class initializer 必须是常量表达式
      • 如果该成员是 class type,那么该类的初始化必须通过自身的 constexpr 构造函数来完成。
    • 必须使用默认定义的析构函数(默认情况下销毁所有对象)。
Constexpr 构造函数

对于 Literal class 的构造函数,我们可以声明为 constexpr;并且,Literal class 至少需要一个 constexpr 构造函数。

对于 constexpr 构造函数:

  • 要么用“ = default ” 声明为默认构造函数。
  • 要么声明为删除函数形式。
  • 要么遵循构造函数和 constexpr 的的特点(前者无返回值,后者的唯一可执行语句是 return 语句);所以函数体一般为空

class Debug {
	public:
	constexpr Debug(bool b = true): hw(b), io(b), other(b) {}
	constexpr Debug(bool h, bool i, bool o): hw(h), io(i), other(o) {}
	constexpr bool any() { return hw || io || other; }
	void set_io(bool b) { io = b; }
	void set_hw(bool b) { hw = b; }
	void set_other(bool b) { hw = b; }
private:
	bool hw; // hardware errors other than IO errors
	bool io; // IO errors
	bool other; // other errors
};
有几点要注意的是:

  • constexpr 构造函数必须初始化所有成员,初始值可以用 constexpr 函数初始化,或者是一个 constant 表达式。
  • constexpr 构造函数用于生成 constexpr 对象。该对象的 parameter 与 返回值 都是 constexpr 类型。

constexpr Debug io_sub(false, true, false); // debugging IO
	if (io_sub.any()) // equivalent to if(true)
		cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false); // no debugging during production
	if (prod.any()) // equivalent to if(false)
		cerr << "print an error message" << endl;

类的静态成员

类的静态成员(Static Class Member与类挂钩,而不是与类的对象挂钩。其旨在提供一个全局的,可以被所有类对象共享的成员

静态类成员的声明

静态类成员的声明在之前添加关键字 static 即可:

static double interestRate;
static double initRate();
注意:

  • 静态类成员存在于类对象之外不与类对象绑定
  • 类对象中没有静态类成员,因此无法对静态类成员使用 this 指针(包括其内部 name),该成员也不能被声明const

class Account {
public:
	void calculate() { amount += amount * interestRate; }
	static double rate() { return interestRate; }
	static void rate(double);
private:
	std::string owner;
	double amount;
	static double interestRate;
	static double initRate();
};

静态类成员的使用

一般情况下,我们用 scope 运算符访问静态类成员:

double r;
r = Account::rate();
不过即便静态类成员不属于具体的类的对象,我们也依然可以用类的对象,类的引用,或者类的指针来访问它们:
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate(); //through an Account object or reference
r = ac2->rate(); //through a pointer to an Account object
而且,成员函数可以直接使用静态变量

静态类成员的定义

  • 静态成员需要在类中声明
  • 静态成员需要在类外初始化,初始化时需要加上类 scope

class Account
{
    static double inserestRate;
};
//init
double Account::interestRate = constant expression;
static 关键字使用的位置是?

Static 关键字只需要在成员声明的地方使用一次即可。定义该成员不需要再使用。

静态类成员初始化和定义的要求是?

  • 必须在类的外部定义和初始化
  • 只能定义一次
  • 静态成员定义的位置最好是在类定义的文件中,与 non-Inline 函数的定义放置在一起。

构造函数与静态类成员有关系吗?

没有。由于静态类成员不是类对象的一部分,因此构造函数不会对其进行初始化。

静态类成员的生命周期与 scope?

静态类成员会一直存在到程序结束。其 scope 为整个类的 scope。静态类成员函数享有一切类成员函数的权限。

在类中初始化静态成员
  • 静态类成员可以在类中以 const integral type 的常量表达式完成初始化
  • constexpr 类型的静态类成员必须以 const integral type 的常量表达式完成初始化

比如使用初始化的静态类成员作为数组的维度:

static constexpr int period = 30; //period is a constant expression
double daily_tbl[period];
需要注意的是,即便我们在这里对 period 初始化了,但某种意义上,这种初始化并不是我们所理解的常规 “初始化”。实际上,该初始化只是在编译期做了“值的替换”。因此,如果要将该值使用到其他地方,必须再次重新定义

比较保险的做法是,在外部重新定义一下在类内部初始化过的的 constexpr 静态类成员。

Ref:C++中const/constexpr static成员数据的初始化问题

静态成员和普通成员的区别

静态成员和普通成员的区别主要在于使用的场景:

  • 静态成员可以是 incomplete type。这种 incomplete type 可以是其所属类的类型(普通成员必须以指针或者应用的形式才能作为 incomplete type):

class Bar {
public:
// ...
private:
	static Bar mem1; // ok: static member can have incomplete type
	Bar *mem2; // ok: pointer member can have incomplete type
	Bar mem3; // error: data members must have complete type
};

  • 静态成员可以作为 default argument,因为其独立于类对象之外。如果使用非静态成员作为 default argument,那么该成员实际上是来源于一个没有完成初始化的类对象,是无效的,不存在的 argument。