C++Primer 第七章 类
# 第七章 类
## 7.1.2改进和定义Sales_data类
```c++
struct Sales_data{
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
};
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ofstream &print(std::ostream&, const Sales_data&);
std::ifstream &read(std::ifstream&, Sales_data&);
成员函数可以必须在类内声明,可以定义在类的内部也可以定义在类的外部。
引入this
std::string isbn() const { return bookNo; }
,isbn函数是如何确定bookNo成员所依赖的对象?
total.isbn()
对象使用点运算符来实现对成员函数的调用,如果成员函数中含有Sales_data的成员,则它隐式的指向调用该函数的对象的成员。所以上面的调用中,当isbn返回bookNo时,实际上它隐式的返回total.bookNo
成员函数通过一个名为this的额外的隐式参数来访问调用它的对象,当我们调用一个成员函数时,用请求该函数的对象地址初始化this。当调用total.isbn()
时,编译器负责把total的地址传递给isbn的隐式形参this。
在成员函数内部,可以直接使用调用该函数的对象的成员,无需通过成员访问运算符来做到。isbn调用bookNo时,相当于this->bookNo
。该函数可以改写成:
std::string isbn() const { return this->bookNo; }
使用this的目的是总是指向当前对象,所以this是一个常量指针,不允许改变this中保存的地址
引入const
成员函数
参数列表之后的const关键字,const的作用是修改隐式this指针的类型。
默认情况下this的类型是指向非常量类类型的常量指针。所以在默认情况下不能将this绑定到一个常量对象上,这就导致我们不能对一个常量对象调用普通的成员函数。为了能够调用常量对象我们可以把this定义成指向常量类型的常量指针,但是this是隐式的在哪里声明呢?
C++允许把const关键字放在成员函数的参数列表之后,这样跟在参数列表之后的const表示this是一个指向常量的指针,使用const的成员函数称作常量成员函数。
#include <iostream>
class Test {
public:
int value;
//构造函数
Test(int v = 0) : value(v){};
int getvalue() { return this->value; }
};
int main(int argc, char const *argv[]) {
const Test t(5); //声明称const类型会编译错误
std::cout << t.getvalue() << std::endl;
return 0;
}
// .1.2.cc : In function ‘int main(int, const char **)’: 7.1.2.cc : 28 : 27 : error
// : passing ‘const Test’ as ‘this’ argument discards
// qualifiers[-fpermissive] std::cout
// << t.getvalue()
// << std::endl;
// ^7.1.2.cc : 23 : 7 : note : in call to ‘int Test::getvalue()’ int getvalue() {
// return this->value;
// }
#include <iostream>
class Test {
public:
int value;
//构造函数
Test(int v = 0) : value(v){};
int getvalue() const { return this->value; }
};
int main(int argc, char const *argv[]) {
Test t(5); //正常运行
std::cout << t.getvalue() << std::endl;
return 0;
}
在类的外部定义成员函数
在类的外部定义成员函数时要求成员函数的返回类型,参数列表和函数名都和类内部的声明是一直的。在外部定义成员函数时注意加上类名和作用域运算符::
,这样编译器就可以知道该函数位于类的作用域。
double Sales_data::avg_price() const {
if (units_sold)
return revenue / units_sold;
else
return 0;
}
定义一个返回this对象的函数
调用combine函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显示的实参传入函数
Sales_data& Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this; //返回调用该函数的对象
}
total.combine(trans)
,处理该函数,total的地址被隐式的绑定到this上,rhs被绑定到trans上,因此当执行如下语句时:
units_sold += rhs.units_sold
效果等同于:total.units_sold += trans.units_sold
最后一句return *this
通过解引用this指针来获得执行该函数的对象,该函数返回total的引用。
7.1.4构造函数
类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,只要对象被创建就会执行构造函数。
构造函数的名字和类名相同,构造函数没有返回类型,一个类中可以有多个构造函数,不同的构造函数在参数数量和参数类型上必须有所差别。
合成的默认构造函数
在定义Sales_data total
的时候,没有为这些对象提供初始值,但是执行了默认初始化过程,通过默认构造函数来执行默认初始化过程。没有显式的定义构造函数,编译器就会为我们隐式地定义一个默认构造函数(只有当类没有生命任何构造函数时,编译器才会为我们自动生成默认构造函数)。
某些类不能依赖于合成的默认构造函数
-
合成的默认构造函数只适用于非常简单的类
-
对于某些类来说合成的默认构造函数可能执行错误地操作,含有内置类型或复合类型成员的类应该在类地内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类地对象时就可能得到未定义的值。在类内包含有内置类型或者符合内置类型的成员,只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数
-
有的时候编译器不能为某些类合成默认的构造函数,例如:如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器无法初始化该成员
定义Sales_data类的默认构造函数
Sales_data类定义了4个不同的默认构造函数
class Sales_data {
public:
//默认构造函数
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 &);
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
std::string isbn() const { return this->bookNo; }
Sales_data &combine(const Sales_data &);
double avg_price() const;
};
=default
的含义
Sales_data() = default;
该构造函数不接受任何实参所以是默认构造函数,定义该默认构造函数是因为需要其他形式的构造函数,也需要默认的构造函数。
在C++11中,如果需要默认的行为,可以在参数列表后面加上=default
来要求编译器生成构造函数。=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) {}
冒号后面的是构造函数初始值列表,为新创建的对象一个或几个数据成员赋值
7.2访问控制与封装
在C++中使用访问说明符加强类的封装性
-
定义在public说明符之后的成员在整个程序内可被访问,Public定义类的接口
-
定义在Private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private封装了类的实现细节
class Sales_data {
//默认构造函数
public:
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 &is) { read(is, *this); }
std::string isbn() const { return this->bookNo; }
Sales_data &combine(const Sales_data &);
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
double avg_price() const;
};
使用class或struct关键字
class和struct关键字都能定义类,唯一的区别就是默认的访问权限
类可以在定义它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。
使用struct关键字,定义在第一个访问说明符之前的成员是public,使用class关键字,这些成员是Private。
如果希望所有成员是public时采用struct,如果希望成员是private,使用class
7.2.1友元
class Sales_data {
//友元声明
friend Sales_data add(const Sales_data &, const Sales_data &);
friend std::istream &read(std::istream &, Sales_data &);
friend std::ostream &outPut(std::ostream &, const Sales_data &);
public:
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 &is) { read(is, *this); }
std::string isbn() const { return this->bookNo; }
Sales_data &combine(const Sales_data &);
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
double avg_price() const;
};
// Sales_data的非成员接口函数
Sales_data add(const Sales_data &, const Sales_data &);
std::ostream &outPut(std::ostream &, const Sales_data &);
std::istream &read(std::istream &, Sales_data &);
Sales_data中的数据定义成private,非成员接口函数就无法直接调用这些数据。
类要想其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元。采用friend
关键字来声明友元。
友元声明只能出现在类定义的内部,具体位置不限(一般在类定义开始或结束前的位置集中声明友元)
友元的声明
友元的声明仅定义了访问权限,而非一个通常意义上的函数声明。如果希望用户能够调用某个友元函数,必须在友元声明之外再专门对函数进行一次声明。
7.3类的其他特性
7.3.2返回*this
的成员函数
#include <string>
class Screen {
public:
using pos = std::string::size_type;
Screen() = default;
// cursor被其类内初始值初始化为0
Screen(pos ht, pos wd, char c)
: height(ht), width(wd), contents(ht * wd, c) {}
Screen(pos ht, pos wd) : height(ht), width(wd), contents(ht * wd, ' ') {}
char get() const {
return contents[cursor];
} //类内定义的函数没有inline的话都是隐式内联
//成员函数重载
inline char get(pos ht, pos wd) const; //显式内联
Screen &move(pos r, pos c); //再之后被设为内联
void some_member() const;
Screen &set(char);
Screen &set(pos, pos, char);
//根据对象是否是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:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
mutable size_t access_ctr;
//负责显示screen的内容
void do_display(std::ostream &os) const { os << contents; }
};
//在函数的定义处指定inline
inline Screen &Screen::move(pos r, pos c) {
pos row = r * width;
cursor = row + c;
return *this;
}
char Screen::get(pos r, pos c) const {
pos row = r * width;
return contents[row + c]; //返回给定列的字符
}
void Screen::some_member() const { ++access_ctr; }
inline Screen &Screen::set(char c) {
contents[cursor] = c;
return *this; //将this对象作为左值返回
}
inline Screen &Screen::set(pos r, pos col, char ch) {
contents[r * width + col] = ch; //设置给定位置新值
return *this; //将this对象作为左值返回
}
set函数返回的时对象的引用,返回引用的函数是左值的,表明函数返回的是对象本身而非对象的副本。
把一系列这样的操作连接在一条表达式:
myScreen.move(4.0).set('#');
,这些操作将在同一个对象上执行。
上述语句等价于:myScreen.move(4, 0);
, myScreen.set('#')
如果move和set返回的不是对象的引用,而是Screen,上述语句表达的效果完全不一样。
//如果move返回screen而非screen$
Screen temp = myScreen.move(4, 0); //对返回值进行拷贝
temp.set('#'); //不会改变myScreen的contents
基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载。
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; }
当一个成员函数调用另外一个成员时,this指针在其中隐式地传递,display调用do_diaplay时,它的this指针隐式地传递给do_diplay,而当display地非常量版本调用do_display时,它的this指针隐式地从指向非常量地指针转换成指向常量的指针。
7.3.3类类型
struct First{
int memi;
int getMem();
};
struct Second{
int memi;
int getMem();
};
First obj1;
Second obj2 = obj1; //错误,类型不同
每个类定义了唯一的类型。如果两个类的成员完全一样这两个类也是不同的类型。
7.3.4友元再探
class Window_mgr {
public:
using ScreenIndex = std::vector<Screen>::size_type;
void clear(ScreenIndex);
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
void Window_mgr::clear(ScreenIndex i) {
Screen &s = screens[i];
s.contents = std::string(s.height * s.width, ' ');
}
friend class Window_mgr;
在Screen类中我们将Window_mgr
指定为友元类,则友元类的成员函数可以访问该类包括非公有成员在内的所有成员。友元不具有传递性,Window_mgr
的友元只负责控制自己的类和友元函数不具有Screen
的性质。
另成员函数成为友元
friend void Window_mgr::clear(ScreenIndex);
,处了令整个Window_mgr
作为友元之外,Screen还可以只为clear提供访问权限,声明一个成员函数成友元时,必须指出该成员函数属于哪个类。
要想令某个成员函数成为友元,必须按照一下方式设计程序:
-
首先定义
Window_mgr
类,其中声明clear函数,但是不能定义它。在clear中使用Screen的成员之前必须先声明Screen -
接下来定义Screen,包括对clear的友元声明
-
最后定义clear,此时可以使用Screen的成员。
函数重载和友元
重载函数名字相同,但还是不同的函数。如果一个类想把一组重载函数声明成它的友元,需要分别声明。
//重载的storeOn函数
extern std::ostream& storeOn(std::ostream &, Screen &);
extern BitMap& storeOn(BitMap &, Screen &);
class Screen{
friend std::ostream& storeOn(std::ostream &, Screen &);
};
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前,当一个名字第一次出现在一个友元声明中,假定该名字在当前作用域是可见的。
在类的内部定义该函数也必须在类的外部提供相应的声明使得函数可见。即声明友元的类的成员调用该友元函数,也必须是被声明过的。
struct X{
friend void f() { /*友元函数可以定义在类的内部*/}
X() {f ();} //错误,f没有被声明
void g();
void h();
};
void X::g() { return f(); } //错误,f没有被声明
void f(); //声明该函数
void X::h() { return f(); } //正确:现在f的声明在作用域中
7.4类的作用域
7.4.1名字查找与类的作用域
名字查找的过程:
-
在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
-
没找到继续查找外层作用域
-
没找到最终匹配的声明,报错
类的定义分为两步:
-
编译成员的声明
-
直到类全部可见后才编译函数体
编译器处理完类中的全部声明后才会处理成员函数的定义
正是因为这种方式,成员函数能使用类中定义的任何名字
用于类成员声明的名字查找
以上两个阶段只适用于成员函数中使用的名字,声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须确保可见。
typedef double Money;
string bal;
class Account {
public:
Money balance() { return bal; }
private:
Money bal;
//....
};
编译器现在Account类中寻找Money的声明,没有找到才会在Account的外层作用域查找。
类型名要特殊处理
一般来讲,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。但是在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能再之后重新定义该名字。
typedef double Money;
class Account {
public:
Money balance() { return val; }
private:
typedef double Money; //错误,不能重新定义Money
Money val;
//...
};
成员定义在普通块作用域的名字查找
成员函数中使用的名字解析方式:
-
在成员函数内查找该名字的声明。只有函数使用之前出现的声明才被考虑
-
如果在成员函数中没有找到,在类内中继续查找,此时类的所有成员都可以被考虑
-
如果类内也没找到,在成员函数定义之前的作用域继续查找
ps:一下代码进攻参考,一般不建议使用成员函数的名字作为某个成员的参数。
#include <iostream>
#include <string>
int height;
class Screen {
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height) {
cursor = width * height;
std::cout << width << " " << height << std::endl;
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};
int main(int argc, char const *argv[]) {
height = 30;
Screen t;
t.dummy_fcn(10);
return 0;
}
//0 10
编译器处理dummy_fcn的时候,先在函数作用域内查找表达式中用到的名字。函数的参数位于函数作用域内,因此dummy_fcn函数体内用的名字height指的是参数声明。
如果要想使用同名的成员可以采用以下方法:
void dummy_fcn(pos height) {
cursor = width * this->height;
std::cout << width << " " << this->height << std::endl;
}
类作用域之后在外围的作用域查找
如果在类内都没有找到对应的名字,就会在外围的作用域中查找。如以上代码中的int height
定义在外围作用域中,在类内被隐藏掉了,如果像访问可以通过作用域运算符来请求。但是不建议这种写法。
void dummy_fcn(pos height) {
cursor = width * this->height;
std::cout << width << " " << ::height << std::endl; //全局height
}
7.5构造函数再探
7.5.1 构造函数初始值列表
如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。
Sales_data::Sales_date(const string &s, usigned cnt, double price) bookNo(s), units_sole(cnt), revenue(cnt * price) {}
Sales_data::Sales_date(const string &s, usigned cnt, double price) {
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
以上两段代码执行初始化的效果是相同的。区别是第一种是初始化它的数据成员,第二种是对数据成员进行赋值操作。二者深层次的区别依赖去数据成员的类型
构造函数在有些情况下只能使用初始化
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
ConstRef::ConstRef(int ii) {
i = ii;
ci = ii; //错误,ci为常量类型必须初始化
ri = i; //ri为引用必须初始化
}
//正确的写法
ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(ii) {}
对于初始化和赋值在底层效率之间的区别:初始化是直接初始化数据成员,赋值是先初始化然后在赋值。这些区别就导致了对于
const、引用、属于某种未提供默认构造函数的类类型必须通过构造函数初始值列表为这些函数提供初值。
成员函数初始化的顺序
按照成员在类中的出现顺序来初始化,而非按照构造函数初始值列表的顺序进行初始化,构造函数初始值列表的顺序尽量按照在类中的出现顺序来编写
class X {
int i;
int j;
public:
X(int val) : j(val), i(val) {} //初始化顺序是先初始化i,在初始化J
};
7.5.2委托构造函数
class Sales_data {
public:
//非委托构造函数
Sales_data(std::string s, unsigned cnt, double price) :
bookNo(s), units_sold(cnt), revenue(price* cnt) {}
//委托构造函数
//先定义一个默认构造函数,他把他的功能委托给了三参数构造函数
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); }
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
double avg_price() const;
};
委托构造函数的执行顺序:先执行委托者的初始值列表,然后在执行委托构造函数的参数值列表和函数体,最后执行委托者的函数体
7.5.4隐式类型转换
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 &Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
string null_book = "9-999-999999-9";
Sales_data item;
item.combine(null_book);
在上面这段语句当中使用string实参调用了combine成员,这种调用合法,编译器用给定的string自动创建一个临时的Sales_data对象,新生成的对象传递给combine,其中bookNo初始化为null_book, revenue和units_sold默认为0.
类的隐式类型转换只对一个实参的构造函数有效,含有多个实参的构造函数不能用于执行隐式转换。
编译器只会自动执行一步类型转换
如果代码隐式的使用了两种类型转换规则,说明是错误的。
item.comine("9-999-999999-9");
上面这段代码是错误的,因为它隐式的包含了两种类型转换,先将"9-999-999999-9"
转换成string
类型,然后再将string
转换成Sales_data
类型。
正确写法:
//正确,显式的转换成string,在隐式的转换成Sales_data
item.combine(string("9-999-999999-9"));
//正确,隐式的转换成string,在显式的转换成Sales_data
item.combine(Sales_data("9-999-999999-9"));
抑制构造函数定义的隐式转换
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&);
};
item.combine(null_book); //错误:string构造函数为explicit
item.combine(cin); //错误:istream构造函数explicit
利用explicit
关键字对隐式类型转换加以阻止,explicit
关键字只对含有一个实参的构造函数有效对多个的无效,同时explicit
关键字只能在类的内部使用,在类的外部无法使用。
explicit
构造函数只能用于直接初始化
当我们执行拷贝形式的初始化时会发生隐式类型转换,此时只能使用直接初始化的方式
Sales_data item1(null_book); //正确,直接初始化
Sales_data item2 = null_book; //错误,会发生隐式类型转换,explicit会抑制隐式类型转换
为转换显式的使用构造函数
使用explicit
关键字可以抑制隐式类型转换,要想转换我们可以采用显式的类型转换。
item.combine(Sales_data(null_book)); //显式构造Sales_data对象
item.combine(static_cast<Sales_data>(cin)); //使用static_cast执行强制类型转换
7.5.5聚合类
满足如下条件的类是聚合类:
- 所有成员是public
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,没有virtual函数
下面这个类是一个聚合类
struct Data {
int ival;
string s;
};
//可以直接使用{}来进行初始化,注意初始化顺序要和类内的成员顺序是一致的
Data val1 = {0, "Anna"};
7.6类的静态成员
有些类需要他的一些成员直接与类本身直接相关,而不是和类的对象相关,这个时候可以使用静态成员
声明静态成员
class Account {
public:
// Account(std::string name, double num) : owner(name), amount(num) {}
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();
};
在成员的声明之前加上关键字static
使其与类关联在一起,类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据,静态成员函数也不与任何对象绑定,不包含this指针,静态成员函数不能声明成const,不能在static函数内部使用this指针
使用类的静态成员
double r;
r = Account::rate();
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();
使用作用域运算符来访问,虽然静态成员不属于类的某个对象,当时仍然可以使用类的对象、引用或者指针来访问静态成员
定义静态成员
//在类的内部已经声明static关键字了,在类的外部定义时就没必要重复声明
void Account :: rate(double newRate) {
interestRate = newRate;
}
//定义并初始化静态成员,需要类名,作用域运算符以及成员自己的名字
double Account::interestRate = initRate();
静态数据成员不属于类的任何一个对象,并不是创建类的对象时被定义的,不是由类的构造函数初始化,不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员。
静态成员能用于某些场景,而普通成员不能
class Bar {
public:
private:
static Bar mem1; //正确:静态数据成员可以是不完全类型
Bar *mem2; //正确:指针成员可以是不完全类型
Bar mem3; //error-type,数据成员必须是完全类型
};
静态数据成员可以作为默认实参,但是普通成员不行。因为静态数据成员不属于对象的一部分,但是非静态数据成员的值属于对象的一部分
class Screen{
public:
Screen& clear(char = bkground);
private:
static const char bkgound;
};