《C++ Primer》学习笔记(七):对象和类

专栏C++学习笔记

《C++ Primer》学习笔记/习题答案 总目录

——————————————————————————————————————————————————————

📚💻 Cpp-Prime5 + Cpp-Primer-Plus6 源代码和课后题

对象和类

类的基本思想是 数据抽象(data abstraction)封装(encapsulation)。数据抽象是一种依赖于 接口(interface)实现(implementation) 分离的编程及设计技术。类的接口包括用户所能执行的操作;类的实现包括类的数据成员、负责接口实现的函数体以及其他私有函数。

封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。

1、定义抽象数据类型

本章主要使用的 Sales_data 类是一个抽象数据类型,通过它的接口可以使用它,但是不能访问它的数据成员,因为事实上,我们甚至根本不知道这个类有哪些数据成员。

1)设计Sales_data类

Sales_item 类有一个名为 isbn成员函数(member function), 并且支持 + 、= 、+= 、<< 和 >> 运算符。

Sales_data 的接口应该包含以下操作:

  • 一个 isbn 成员函数,用于返回对象的 ISBN 编号
  • 一个 combine 成员函数,用于将一个 Sales_data 对象加到另一个对象上
  • 一个 add 的函数,执行两个 Sales_data 对象的加法
  • 一个 read 函数,将数据从 istream 读入到 Sales_data 对象中
  • 一个 print 函数, 将 Sales_data 对象的值输出到 ostream

程序员们常把运行其程序的人称作 用户(user) 。类的用户是程序员,而非应用程序的最终使用者。

2)定义改进的Sales_data类

成员函数(member function) 的声明必须在类的内部,定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,它们的定义和声明都在类的外部。

struct Sales_data
{
    // 新成员:关于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的非成员接口函数
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 中保存的地址。

total.isbn()
// 伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&total)
std::string isbn() const { return this->bookNo; }

默认情况下,this 的类型是指向类类型非常量版本的常量指针。this 也遵循初始化规则,所以默认不能把 this 绑定到一个常量对象上,即不能在常量对象上调用普通的成员函数。

C++允许在成员函数的参数列表后面添加关键字 const,表示 this 是一个指向常量的指针。使用关键字 const 的成员函数被称作 常量成员函数(const member function)

// 伪代码,说明隐式的this指针是如何使用的
// 下面的代码是非法的:因为我们不能显式地定义自己的this指针
// 谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员
std::string Sales_data::isbn(const Sales_data *const this)
{ return this->isbn; }

常量对象和指向常量对象的引用或指针都只能调用常量成员函数。

类本身就是一个作用域,成员函数的定义嵌套在类的作用域之内。

编译器处理类时器分两步处理类,

  • 首先会是编译成员声明,
  • 然后才轮到编译成员函数体(如果有的话),

因此,成员函数可以随意使用类的其他成员而无须在意这些成员的出现顺序。

在类的外部定义成员函数时,成员函数的定义必须与它的声明相匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员函数被声明为常量成员函数,那么它的定义也必须在参数列表后面指定 const 属性。同时,类外部定义的成员名字必须包含它所属的类名。

// 声明在类Sales_data的作用域内定义了一个名为avg_price的函数
double Sales_data::avg_price() const 
{
    if (units_sold)
        return revenue / units_sold;
    else
        return 0;
}

可以定义返回 this 对象的成员函数。

Sales_data& Sales_data::combine(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;   // 把rhs的成员加到this对象的成员上
    revenue += rhs.revenue;
    return *this;       			// 返回调用该函数的对象
}

其中,return 语句解引用 this 指针以获得执行该函数的对象。

3)定义类相关的非成员函数

类的作者通常会定义一些辅助函数,尽管这些函数从概念上来说属于类接口的组成部分,但实际上它们并不属于类本身。

如果非成员函数是类接口的组成部分,则这些函数的声明应该与类放在同一个头文件中。

// 输入的交易信息包括ISBN、售出总数和售出价格
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;
}
Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
	Sales_data sum = lhs; 	// 把lhs的数据成员拷贝给sum
	sum.combine(rhs); 		// 把rhs的数据成员加到sum当中
	return sum;
}

一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代归来决定是有换行。

4)构造函数

类通过一个或几个特殊的成员函数来控制其对象的初始化操作,这些函数被称作 构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,只要类的对象被创建,就会执行构造函数。

构造函数的名字和类名相同,和其他函数不一样的是,构造函数没有返回类型,且不能被声明为 const 函数。

构造函数在 const 对象的构造过程中可以向其写值。

类通过一个特殊的构造函数来控制默认初始化过料, 这个函数叫做 默认构造函数(default constructor)。默认构造函数无须任何实参。

struct 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(p*n) { }
    Sales_data(std::istream &);
    // 之前已有的其他成员
};

类通过 默认构造函数(default constructor) 来控制默认初始化过程,默认构造函数无须任何实参。

如果类没有显式地定义构造函数,则编译器会为类隐式地定义一个默认构造函数,该构造函数也被称为 合成的默认构造函数(synthesized default constructor)。对于大多数类来说,合成的默认构造函数初始化数据成员的规则如下:

  • 如果存在类内初始值,则用它来初始化成员。
  • 否则默认初始化该成员。

某些类不能依赖于合成的默认构造函数。

  • 只有当类没有声明任何构造函数时,编译器才会自动生成默认构造函数。一旦类定义了其他构造函数,那么除非再显式地定义一个默认的构造函数,否则类将没有默认构造函数。
  • 如果类包含内置类型或者复合类型的成员,则只有当这些成员全部存在类内初始值时,这个类才适合使用合成的默认构造函数。否则用户在创建类的对象时就可能得到未定义的值。
  • 编译器不能为某些类合成默认构造函数。例如类中包含一个其他类类型的成员,且该类型没有默认构造函数,那么编译器将无法初始化该成员。

在C++11中,如果类需要默认的函数行为,可以通过在参数列表后面添加 =default 来要求编译器生成构造函数。其中 =default 既可以和函数声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果 =default 在类的内部,则默认构造函数是内联的。

Sales_data() = default;

构造函数初始值列表(constructor initializer list) 负责为新创建对象的一个或几个数据成员赋初始值。形式是每个成员名字后面紧跟括号括起来的(或者在花括号内的)成员初始值,不同成员的初始值通过逗号分隔。

Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
    bookNo(s), units_sold(n), revenue(p*n) { }

当某个数据成员被构造函数初始值列表忽略时,它会以与合成默认构造函数相同的方式隐式初始化。

// 与上面定义的那个构造函数效果相同
Sales_data(const std::string &s):
    bookNo(s), units_sold(0), revenue(0) { }

构造函数不应该轻易覆盖掉类内初始值,除非新值与原值不同。如果编译器不支持类内初始值,则所有构造函数都应该显式初始化每个内置类型的成员。

使用 this 来把对象当成一个整体访问,而非直接访问对象的某个成员。

Sales_data::Sales_data(std::istream &is)
{
	read(is, *this); // read函数的作用是从is中读取一条交易信息然后存入this对象中
}

5)拷贝、赋值和析构

编译器能合成拷贝、赋值和析构函数,但是对于某些类来说,合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本通常会失效。

使用 vector 或者 string 的类能避免分配和释放内存带来的复杂性。

2、访问控制与封装

使用 访问说明符(access specifier) 可以加强类的封装性:

  • 定义在 public 说明符之后的成员在整个程序内都可以被访问。public 成员定义类的接口。
  • 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问。private 部分封装了类的实现细节。
class Sales_data 
{
public: 	// 添加了访问说明符
    Sales_data() = default;
    Sales_data(const std::string &s, unsigned n, double p):
    bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(std::istream&);
    std::string isbn() const { return bookNo; }
    Sales_data &combine(const Sales_data&);
    
private: 	// 添加了访问说明符
    double avg_price() const
    	{ return units_sold ? revenue/units_sold : 0; }   
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

作为接口的一部分,

  • 构造函数和部分成员函数 紧跟在 public 说明符之后;
  • 数据成员和作为实现部分的函数 则跟在 private 说明符后面。

一个类可以包含零或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围到出现下一个访问说明符或类的结尾处为止。

  • 使用关键字 struct 定义类时,定义在第一个访问说明符之前的成员是 public 的;
  • 而使用关键字 class 时,这些成员是 private 的。
    二者唯一的区别就是默认访问权限不同。

1)友元

类可以允许其他类或函数访问它的非公有成员,方法是使用关键字 friend 将其他类或函数声明为它的 友元(friend)

class Sales_data 
{
// 为Sales_data的非成员函数所做的友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
friend std::istream &read(std::istream&, Sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&);
// 其他成员及访问说明符与之前一致
public:
    Sales_data() = default;
    Sales_data(const std::string &s, unsigned n, double p):
    	bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(std::istream&);
    std::string isbn() const { return bookNo; }
    Sales_data &combine(const Sales_data&);
private:
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};
// Sales_data接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);

友元声明只能出现在类定义的内部,具体位置不限。友元不是类的成员,也不受它所在区域访问级别的约束。

通常情况下,最好在类定义开始或结束前的位置集中声明友元。

封装的好处:

  • 确保用户代码不会无意间破坏封装对象的状态。
  • 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。

友元声明仅仅指定了访问权限,而并非一个通常意义上的函数声明。如果希望类的用户能调用某个友元函数,就必须在友元声明之外再专门对函数进行一次声明(部分编译器没有该限制)。

为了使友元对类的用户可见,通常会把友元的声明(类的外部)与类本身放在同一个头文件中。

许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。

一些编译器允许在尚无友元函数的初始声明的情况下就调用它。不过即使你的编译器支持这种行为,最好还是提供一个独立的函数声明。这样即使你更换了一个有这种强制要求的编译器,也不必改变代码。

3、类的其他特性

1)类成员再探

由类定义的类型名字和其他成员一样存在访问限制,可以是 publicprivate 中的一种。

class Screen 
{
public:
	typedef std::string::size_type pos;
private:
	pos cursor = 0;
	pos height = 0, width = 0;
	std::string contents;

// *******************

public:
    // 使用类型别名等价地声明一个类型名字
    using pos = std::string::size_type;
    // 其他成员与之前的版本一致
};

与普通成员不同,用来定义类型的成员必须先定义后使用。类型成员通常位于类起始处。

定义在类内部的成员函数是自动内联的。

如果需要显式声明内联成员函数,建议只在类外部定义的位置说明 inline

和我们在头文件中定义 inline 函数的原因一样,inline 成员函数也该与类定义在同一个头文件中。

使用关键字 mutable 可以声明 可变数据成员(mutable data member)。可变数据成员永远不会是 const 的,即使它在 const 对象内。因此 const 成员函数可以修改可变成员的值。

class Screen 
{
public:
    void some_member() const;
private:
    mutable size_t access_ctr;  // 即使在一个const对象内也能被修改
    // 其他成员与之前的版本一致
};
void Screen::some_member() const
{
    ++access_ctr;   // 保存一个计数值,用于记录成员函数被调用的次数
    // 该成员需要完成的其他工作
}

提供类内初始值时,必须使用 = 或花括号形式。

2)返回*this的成员函数

const 成员函数如果以引用形式返回 *this,则返回类型是常量引用。

通过区分成员函数是否为 const 的,可以对其进行重载。因为非常量版本的函数对于常量对象是不可用的,在常量对象上只能调用 const 版本的函数;在非常量对象上,尽管两个版本都能调用,但显然会选择非常量版本,因为是一个更好的匹配。

class Screen 
{
public:
    // 根据对象是否是const重载了display函数
    Screen &display(std::ostream &os)
    { do_display(os); return *this; }
    const Screen &display(std::ostream &os) const
    { do_display(os); return *this; }
    
private:
    // 该函数负责显示Screen的内容
    void do_display(std::ostream &os) const
    { os << contents; }
    // 其他成员与之前的版本一致
};

Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('#').display(cout);    // 调用非常量版本
blank.display(cout);    			// 调用常量版本

3)类类型

每个类定义了唯一的类型。即使两个类的成员列表完全一致,它们也是不同的类型。

我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,也可以把类名跟在关键字 classstruct 后面:

Sales_data iteml;			// 默认初始化Sales_data类型的对象
class Sales_data iteml;		// 一条等价的声明

可以仅仅声明一个类而暂时不定义它。这种声明被称作 前向声明(forward declaration),用于引入类的名字。在类声明之后定义之前都是一个 不完全类型(incomplete type)

class Screen;   // Screen类的声明

不完全类型只能在非常有限的情景下使用:

  • 可以定义指向不完全类型的指针或引用,
  • 也可以声明(不能定义)以不完全类型作为参数或返回类型的函数。

必须首先完成类的定义,然后编译器才能知道存储该数据成员需要多少空间。因为只有当类全部完成后才算被定义,所以一个类的成员类型不能是该类本身。

但是一旦类的名字出现,就可以被认为是声明过了,因此类可以包含指向它自身类型的引用或指针。

class Link_screen
{
    Screen window;
    Link_screen *next;
    Link_screen *prev;
};

4)友元再探

除了普通函数,类还可以把其他类或其他类的成员函数声明为友元。此外, 友元函数能定义在类的内部, 这样的函数是隐式内联的。

友元类的成员函数可以访问此类包括非公有成员在内的所有成员。

class Screen 
{
    // Window_mgr的成员可以访问Screen类的私有部分
    friend class Window_mgr;
    // Screen类的剩余部分
};

必须要注意的一点是, 友元关系不存在传递性。

每个类负责控制自己的友元类或友元函数。

友元函数可以直接定义在类的内部,这种函数是隐式内联的。但是必须在类外部提供相应声明令函数可见。

把其他类的成员函数声明为友元时,必须明确指定该函数所属的类名。

如果类想把一组重载函数声明为友元,需要对这组函数中的每一个分别声明。

struct X
{
    friend void f() { /* 友元函数可以定义在类的内部 */ }
    X() { f(); }   // 错误:f还没有被声明
    void g();
    void h();
};

void X::g() { return f(); }     // 错误:f还没有被声明
void f();   					// 声明那个定义在x中的函数
void X::h() { return f(); }     // 正确:现在f的声明在作用域中了

友元声明的作用是影响访问权限,它本身并非普通意义上的声明。请注意,有的编译器并不强制执行上述关于友元的限定规则。

4、类的作用域

当成员函数定义在类外时,返回类型中使用的名字位于类的作用域之外,此时返回类型必须指明它是哪个类的成员。

class Window_mgr
{
public:
    // 向窗口添加一个Screen,返回它的编号
    ScreenIndex addScreen(const Screen&);
    // 其他成员与之前的版本一致
};
// 首先处理返回类型,之后我们才进入Window_mgr的作用域
Window_mgr::ScreenIndex
Window_mgr::addScreen(const Screen &s)
{
    screens.push_back(s);
    return screens.size() - 1;
}

1)名字查找与作用域

编译器处理完类中的全部声明后才会处理成员函数的定义。

成员函数体直到整个类可见后才会被处理,因此它能使用类中定义的任何名字。

声明中使用的名字,包括返回类型或参数列表,都必须确保使用前可见。

  • 如果某个成员的声明使用了类中尚未出现的名字,贝IJ编译器将会在定义该类的作用域中继续查找。
  • 如果类的成员使用了外层作用域的某个名字,而该名字表示一种类型,则类不能在之后重新定义该名字。
typedef double Money;
class Account
{
public:
    Money balance() { return bal; } // 使用外层作用域的Money
private:
    typedef double Money; 			// 错误:不能重新定义Money
    Money bal;
    // ...
};

尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责。一些编译器仍将顺利通过这样的代码,而忽略代码有错的事实。

类型名定义通常出现在类起始处,这样能确保所有使用该类型的成员都位于类型名定义之后。

成员函数中名字的解析顺序:

  • 在成员函数内查找该名字的声明,只有在函数使用之前出现的声明才会被考虑。
  • 如果在成员函数内没有找到,则会在类内继续查找,这时会考虑类的所有成员。
  • 如果类内也没有找到,会在成员函数定义之前的作用域查找。
// 注意:这段代码仅为了说明而用,不是一段很好的代码
// 通常情况下不建议为参数和成员使用同样的名字
int height;   // 定义了一个名字,稍后将在Screen中使用
class Screen
{
public:
    typedef std::string::size_type pos;
    void dummy_fcn(pos height)
    {
        cursor = width * height;  // 哪个height? 是那个参数
    }
private:
    pos cursor = 0;
    pos height = 0, width = 0;
};

尽管类的成员被隐藏了,但我们仍然可以通过加上类的名字或显式地使用 this 指针来强制访问成员。

尽管外层的对象被隐藏掉了,还是可以通过作用域运算符 :: 或显式 this 指针来强制访问被隐藏的类成员。

// 不建议的写法:成员函数中的名字不应该隐藏同名的成员
void Screen::dummy_fcn(pos height) 
{
    cursor = width * this->height;  	// 成员height
    // 另外一种表示该成员的方式
    cursor = width * Screen::height;  	// 成员height
}

// 建议的写法:不要把成员名字作为参数或其他局部变量使用variable
void Screen::dummy_fcn(pos ht) 
{
    cursor = width * height;  			// 成员height
}

5、构造函数再探

1)构造函数初始值列表

  • 如果没有在构造函数初始值列表中显式初始化成员,该成员会在构造函数体之前执行默认初始化。
  • 如果成员是 const、引用,或者是某种未定义默认构造函数的类类型,必须在初始值列表中将其初始化。

当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。

class ConstRef
{
public:
    ConstRef(int ii);
private:
    int i;
    const int ci;
    int &ri;
};

// 正确:显式地初始化引用和const成员
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) { }

如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

最好令构造函数初始值的顺序与成员声明的顺序一致,并且尽量避免使用某些成员初始化其他成员。

如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

2)委托构造函数

C++11扩展了构造函数初始值功能,可以定义 委托构造函数(delegating constructor)。委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。

class Sales_data
{
public:
    // 非委托构造函数使用对应的实参初始化成员
    Sales_data(std::string s, unsigned cnt, double price):
    	bookNo(s), units_sold(cnt), revenue(cnt*price) {  }
    // 其余构造函数全都委托给另一个构造函数
    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); }
    // 其他成员与之前的版本一致
}

3)默认构造函数的作用

当对象被默认初始化或值初始化时会自动执行默认构造函数。

默认初始化的发生情况:

  • 在块作用域内不使用初始值定义非静态变量或数组。
  • 类本身含有类类型的成员且使用合成默认构造函数。
  • 类类型的成员没有在构造函数初始值列表中显式初始化。

值初始化的发生情况:

  • 数组初始化时提供的初始值数量少于数组大小。
  • 不使用初始值定义局部静态变量。
  • 通过 T() 形式(T 为类型)的表达式显式地请求值初始化。

类必须包含一个默认构造函数。

在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。

如果想定义一个使用默认构造函数进行初始化的对象,应该去掉对象名后的空括号对。

// 正确:obj是个默认初始化的对象
Sales_data obj;

对于C++的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明一个用默认构造函数初始化的对象:

Sales_data obj();   // 错误:声明了一个函数而非对象
Sales_data obj2;    // 正确:obj2是一个对象而非函数

4)隐式的类类型转换

如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制。这种构造函数被称为 转换构造函数(converting constructor)

能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。

string null_book = "9-999-99999-9";
// 构造一个临时的Sales_data对象
// 该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(null_book);

编译器只会自动执行一步类型转换。

// 错误:需要用户定义的两种转换。
// (1) 把"9-999-99999-9"转换成string
// (2) 再把这个(临时的)string转换成Sales_data
item.combine("9-999-99999-9");
// 正确:显式地转换成string,隐式地转换成Sales_data
item.combine(string("9-999-99999-9"));
// 正确: 隐式地转换成string,显式地转换成Sales_data
item.combine(Sales_data("9-999-99999-9"));

在要求隐式转换的程序上下文中,可以通过将构造函数声明为 explicit 的加以阻止。

class Sales_data
{
public:
    Sales_data() = default;
    Sales_data(const std::string &s, unsigned n, double p):
        bookNo(s), units_sold(n), revenue(p*n) { }
    explicit Sales_data(const std::string &s): bookNo(s) { }
    explicit Sales_data(std::istream&);
    // 其他成员与之前的版本一致
};

explicit 关键字只对接受一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为 explicit 的。只能在类内声明构造函数时使用 explicit 关键字,在类外定义时不能重复。

执行拷贝初始化时(使用 =)会发生隐式转换,所以 explicit 构造函数只能用于直接初始化。

Sales_data item1 (null_book);   // 正确: 直接初始化
// 错误:不能将explicit构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;

当我们用 explicit 关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。

尽管编译器不会将 explicit 的构造函数用于隐式转换过程,可以使用 explicit 构造函数显式地强制转换类型。

// 正确: 实参是一个显式构造的Sales_data对象
item.combine(Sales_data(null_book));
// 正确:static_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(cin));

5)聚合类

聚合类满足如下条件:

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

下面的类是一个聚合类:

struct Data
{
    int ival;
    string s;
};

可以使用一个用花括号包围的成员初始值列表初始化聚合类的数据成员。初始值顺序必须与声明顺序一致。如果初始值列表中的元素个数少于类的成员个数,则靠后的成员被值初始化。

// val1.ival = 0; val1.s = string("Anna")
Data val1 = { 0, "Anna" };

// 错误:不能使用"Anna"初始化ival,也不能使用1024初始化s
Data va12 = { "Anna", 1024 };

6)字面值常量类

数据成员都是字面值类型的聚合类是字面值常量类。或者一个类不是聚合类,但符合下列条件,则也是字面值常量类:

  • 数据成员都是字面值类型。
  • 类至少含有一个 constexpr 构造函数。
  • 如果数据成员含有类内初始值,则内置类型成员的初始值必须是常量表达式。如果成员属于类类型,则初始值必须使用成员自己的 constexpr 构造函数。
  • 类必须使用析构函数的默认定义。

constexpr 构造函数用于生成 constexpr 对象以及 constexpr 函数的参数或返回类型。

constexpr 构造函数必须初始化所有数据成员,初始值使用 constexpr 构造函数或常量表达式。

7、类的静态成员

使用关键字 static 可以声明类的静态成员,可以是 public 的或 private 的。

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();
};

静态成员存在于任何对象之外,对象中不包含与静态成员相关的数据。

类似的, 静态成员函数也不与任何对象绑定在一起。

由于静态成员不与任何对象绑定,因此静态成员函数不能声明为 const 的,也不能在静态成员函数内使用 this 指针。

用户代码可以使用作用域运算符访问静态成员,也可以通过类对象、引用或指针访问。类的成员函数可以直接访问静态成员。

double r;
r = Account::rate(); // 使用作用城运算符访问静态成员

Account ac1;
Account *ac2 = &ac1;
// 调用静态成员函数rate的等价形式
r = ac1.rate(); 	// 通过Account的对象或引用
r = ac2->rate(); 	// 通过指向Account对象的指针

class Account
{
public:
    void calculate() { amount += amount * interestRate; }
private:
    static double interestRate;
    // 其他成员与之前的版本一致
};

在类外部定义静态成员时,不能重复 static 关键字,其只能用于类内部的声明语句。

和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static 关键字则只出现在类内部的声明语句中。

由于静态数据成员不属于类的任何一个对象,因此它们并不是在创建类对象时被定义的。通常情况下,不应该在类内部初始化静态成员。而必须在类外部定义并初始化每个静态成员。一个静态成员只能被定义一次。一旦它被定义,就会一直存在于程序的整个生命周期中。

// 定义并初始化一个静态成员
double Account::interestRate = initRate();

建议把静态数据成员的定义与其他非内联函数的定义放在同一个源文件中,这样可以确保对象只被定义一次。

尽管在通常情况下,不应该在类内部初始化静态成员。但是可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr

初始值必须是常量表达式,因为这些成员本身就是常量表达式, 所以它们能用在所有适合于常量表达式的地方。

class Account
{
public:
    static double rate() { return interestRate; }
    static void rate(double);
private:
    static constexpr int period = 30;  // period是常量表达式
    double daily_tbl[period];
};

即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

静态成员独立于任何对象。

特别的, 静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:

class Bar
{
public:
	// ...
private:
    static Bar mem1;   	// 正确:静态成员可以是不完全类型
    Bar *mem2;    		// 正确:指针成员可以是不完全类型
    Bar mem3;   		// 错误:数据成员必须是完全类型
};

静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参。

class Screen
{
public:
    // bkground表示一个在类中稍后定义的静态成员
    Screen& clear(char = bkground);
private:
    static const char bkground;
};

非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分, 这么做的结果是无法真正提供一个对象以便从中获取成员的值, 最终将引发错误。

如果想要更多的资源,欢迎关注 @我是管小亮,文字强迫症MAX~

回复【福利】即可获取我为你准备的大礼,包括C++,编程四大件,NLP,深度学习等等的资料。

想看更多文(段)章(子),欢迎关注微信公众号「程序员管小亮」~

在这里插入图片描述

参考文章

  • 《C++ Primer》
©️2020 CSDN 皮肤主题: 酷酷鲨 设计师:CSDN官方博客 返回首页