定义抽象数据类型
类的基本思想是数据抽象
和封装
。数据抽象是一种依赖于接口
和实现
分离的编程(以及设计)技术。
1、定义成员函数: 定义在类内部的函数是隐式的inline函数。
- 引入const成员函数: 默认情况下,this的类型是指向类类型非常量版本的常量指针。尽管this是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)我们不能把
this
绑定到一个常量对象
上。这一情况也就使得我们不能在一个常量对象
上调用普通的成员函数
。
当函数体内不会改变this所指的对象时,把this设置为指向常量的指针有助于提高函数的灵活性。然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题了。c++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称为常量成员函数,可以将函数体想象成如下的形式:
// 伪代码,仅用于说明隐式的this指针是如何使用的
// 下面的代码是非法的:因为我们不能显式地定义自己的this指针
// 谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员
std::string Sales_data::isbn(const Sales_data *const this){
return this->isbn;
}
//正确写法
std::string Sale_data::isbn() const{
return this->isbn;
}
注意: 常量对象、以及常量对象的引用或指针都只能调用常量成员函数。
-
类作用域和成员函数: 对于类的处理编译器分为两步,首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无需在意这些成员出现的次序。
-
定义一个返回this对象的成员函数: 下面的combine函数,其设计初衷类似于复合赋值运算符+=,调用该函数的对象代表的是赋值运算对象,右侧运算对象则通过显示的实参被传入函数:
Sales_data& Sales_data::combine(const Sales_data &rhs){
units_sold += rhs.units_sold; //把rhs的成员加到this对象的成员上
revenue += rhs.revenue;
return *this; //返回调用该函数的对象
}
该函数一个值得关注的部分是它的返回类型和返回语句。一般来说,当我们定义的函数类似于某个内置运算符
时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧对象当成左值
返回,因此为了与它保持一致,combine函数必须返回引用类型那个
。因为此时的左侧运算对象是一个Sales_data的对象,所以返回类型应该是Sales_data&。
2、定义类相关的非成员函数: 我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分只需要引入一个文件。
注意: 一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。
3、定义read和print函数:
istream &read(istream &is, Sales_data &item){
double price=0;
is >> item.bookNo >> item.unit_sold >> price;
item.revenue = price*itmen.unit_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item){
os << item,isbn() << " " <<item.units_sold << " "
<< item.revenue << " " <<item.avg_price();
return os;
}
read函数从给定流中将数据读到给定的对象里,print函数则负责将给定对象的内容打印到给定的流中。除此之外关于上面的函数还有两点是非常重要的:第一点是
,read和print分别接受一个各自的IO类型的引用作为其参数,这是因为IO类
属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非是对常量的引用;第二点是
,print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。
访问控制与封装
1、使用class或struct关键字: struct和class唯一一点的区别是,其默认访问权限不一样。类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖与类定义的方式。如果我们使用struct关键字
,则定义在第一个访问说明符之前的成员是public的;相反,如果我们使用class关键字
,则这些成员是private的。
处于统一编程风格的考虑,当我们希望定义的类的所有成员是public的时候,使用stauct;反之,如果希望成员是private的,使用class。
注意 ** 使用class和struct定义类唯一的区别就是默认的访问权限。
2、友元:** 类可以允许其他类
或者函数
访问它的非公有成员
,方法是令其他类或则函数成为它的友元:
class Sales_data{
// 为Sale_data的非成员函数所做的友元声明
friend Sales_data add(const Sales_data& , const Sal es data&) ;
friend std : : istream &read (std : : i stream& , Sales data&);
friend std : :ostream &print(std: :ostream& , const Sales_data&);
public:
.
.
private:
.
.
}
// Sales data 接口的非成员组成部分的声明
Sales data add(const Sales data 品, const Sales data&);
std : :istream &read (std : : istreamι , Sales data& );
std : : ostream &pr 工η t ( s td : : ostream& , const Sales data&) ;
注意:(1)一般来说,最好在类定义开始或结束前的位置集中声明友元。(2)尽管当类的定义发生改变时无需更改用户代码,但是使用了该类的源文件必须重新编译。
友元的声明: 友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。为了使友元对类的用户(程序员)可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,我们的Sales_data头文件应该为read、print和add提供独立的声明
(除了类内部的友元声明之外)。此外,友元函数能定义在类的内部,这样的函数是隐式内联
的。
类之间的友元关系: 如果一个类指定了友元类,则友元类的成员函数可以访问次类包括非公有成员在内的所有成员。不过必须要注意的一点是,友元关系不存在传递关系,而且每个类负责控制自己友元类或友元函数。
令类的成员函数作为友元: 当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:
class Screen{
//Window_mgr::clear必须在Screen类之前被声明
friend void Window_mgr::clear(ScreenIndex);
//Screen类的剩余部分
}
想要令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:
- 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen。
- 接下来定义Screen,包括对于clear的友元声明。
- 最后定义clear,此时它才能使用Screen的成员。
函数重载和友元: 尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。
类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据
和函数成员
只能由对象
、引用
或者指针
使用成员访问运算符来访问。对于类类型成员
则使用作用域运算符
访问。不论哪种情况,跟在运算符之后的名字都必须是对应类的成员。一个类就是一个作用域 的事实能够很好的解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员函数被隐藏起来了。
1、类型名要特殊处理: 一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:
typedef double Money;
class Account{
public:
Money blance(){return bal;} //使用外层作用域的Money
private:
typedef double Money; //错误:不能重新定义Money
Money bal;
}
2、成员定义中普通块作用域的名字查找: 成员函数
中适用的名字按照如下方式解析
- 首先,在
成员函数内
查找该名字的声明,和前面一样,只有在函数使用之前出现过的声明才被考虑。 - 如果在成员函数内没有找到,则在
类内
继续查找,这是类的所有成员都可以被考虑。 - 如果类内也没有找到该名字的声明,在
成员函数定义之前的作用域
内继续查找
构造函数
类的构造函数是一个非常复杂的问题,因此作为一个大模块进行学习。不同于其他成员函数,构造函数不能被声明成const的。
1、合成的默认构造函数: 对于大多数类来说,合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员
- 否则,默认初始化该成员。
对于一个普通的类来说,必须定义它自己的默认构造函数 ,原因有三:
- 第一个原因是:编译器只有在发现类不包含任何构造函数的情况下,才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。
- 第二个原因是:对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数,否则用户在创建类的对象时就可能得到未初始化的值。
- 第三个原因是:有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员并且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
在C++11新标准
中,如果我们需要默认的行为,那么可以通过在参数列表后面写上=default 来要求编译器生成默认构造函数。和其他函数一样,如果=default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
2、构造函数初始化值列表: 通常情况下,构造函数使用 类内初始值不失为一种好的选择,因为只要这样的初始值存在,我们就能确保成员赋予一个正确的值。不过如果你的编译器不支持类内初始值
,则所有构造函数都应该显示地初始化每个内置类型的成员。
3、构造函数的初始值有时必不可少: 有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。
类似的,当成员属于某种类类型
(类的静态成员或全局变量)并且该类没有定义默认构造函数时,也必须将这个成员初始化。例如:
class ConstRef {
public:
ConstRef (int ii ) ;
private :
int i;
const int ci; //ci和ri都必须被初始化,不能进行赋值操作!
int &ri;
};
总之,如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初始值。尽量使用构造函数初始值 ,在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化在赋值。除了效率问题外更重要的是,一些数据成员必须被初始化。
4、成员初始化的顺序: 成员的初始化顺序与它们在类定义中的出现顺序一致,即第一个成员先被初始化,后面第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的话,那么这两个成员的初始化顺序就很关键了:
class X {
int i ;
int j;
public:
//未定义的i在j之前被初始化
X(int val): j (val), i (j) { }
};
注意: 最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始其他成员。
5、默认实参和构造函数: 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
6、默认构造函数的作用: 当对象被默认初始化
或值初始化
时自动执行默认构造函数。默认构造函数在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有
类类型
的成员且使用合成的默认构造函数时。 - 当
类类型
的成员没有在构造函数初始值列表中显示地初始化时。
对于值初始化
则在以下情况下发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
- 当我们不使用初始值定义一个局部静态变量时。
- 当我们通过书写形如T ( )的表达式显式地请求值初始化时,其中T 是类型名 (vector 的一个构造函数只接受一个实参用于说明vector 大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。
7、转换构造函数(隐式的类类型转换): c++除了在内置类型之间定义了几种自动转换规则外,也为类定义了隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称为转换构造函数(能通过一个实参调用
的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则)。
string null_book = " 9-999-99999-9";
// 构造一个临时的Sales data 对象
// 该对象的units_sold 和revenue 等于0 , bookNo 等于null_book
item . combine(null_book); //编译器会用给定的string自动创建一个临时的Sales_data对象
只允许一步类类型转换: 编译器自会自动地执行一步类型转换
// 错误:需要用户定义的两种转换:
// (1) 把"9-999-99999-9" 转换成string
// (2) 再把这个(临时的) string 转换成Sales_data
item.combine (" 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&) ;
//其他成员与之前的版本一致
};
此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的两种用法都无法通过编译:
item.combine(null_book); // 错误string 构造函数是explicit的
item.combine(cin) ; // 错误istream 构造函数是explicit的
Sales_data iteml(null_book) ; // 正确: 直接初始化
注意: 关键字explicit只对一个实参的构造函数有效,explicit构造函数只能用于直接初始化。
8、聚合类: 使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们就说它是聚合的:
- 所有成员都是public的
- 没有定义任何构造函数。
- 没有类内初始化。
- 没有基类,也没有virtual函数。
//例如下面的类是一个聚合类
struct Data{
int ival;
string s;
}
9、字面值常量类: 数据成员都是字面值类型的聚合类是字面值常量类,如果一个类不是聚合类
,但它符合下述要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型。
- 类必须至少有一个
constexpr构造函数
。 - 如果一个数据成员含有
类内初始值
,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。 - 类必须使用
析构函数
的默认定义,该成员负责销毁类的对象。
10、constexpr构造函数: 尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面常量类必须至少提供一个constexpr构造函数。
类的静态成员
1、声明静态成员: 和其他成员一样,静态成员可以是public的或private的,其类型可以是常量、引用、指针、类类型等。同时,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。
2、定义静态成员: 一般来说,我们不能在类的内部初始化 静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。
3、静态成员的类内初始化: 通常情况下,类的静态成员不应该在类的内部初始化,然而,我们可以为静态成员提供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];
};
4、静态成员能用于某些场景,而普通成员不能: 静态成员可以是不完全类型
,特别的,静态成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
class Bar{
public:
//....
private:
static Bar mem1; // 正确:静态成员可以是不完全类型
Bar *mem2; // 正确:指针成员可以是不完全类型
Bar mem3 // 错误:数据成员是完全类型
};
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参
:
class Screen {
public:
// bkground 表示一个在类中稍后定义的静态成员
Screen& clear(char = bkground);
private:
static const char bkground;
};
5、不完全类型: 可以前向声明,也就是先声明而暂时不定义该类型,在它声明之后定义之前的状态就称为不完全类型。
不完全类型只能在非常有限的场景下使用: 可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
我们可以定义指向不完全类型的指针,但是无法创建不完全类型的对象:
class X; //声明类型X
class Y{ //定义类型Y
X* x; //正确
X x1; // 错误:不能创建不完全类型的对象,编译器将会报错
};
class X{ //定义类型X
Y y;
};
6、类类型: 每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是不同的类型。例如:
struct First{
int memi;
int getMen();
};
struct Second{
int memi;
int getMen();
};
First obj1;
Second obj2 = obj1; // 错误:obj1和obj2的类型不同