类的基本思想是数据抽象和封装
数据抽象是一种依赖于接口和实现分离的编程技术
类的实现包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数
1.类的所有成员都必须在类的内部声明,但是成员函数体可以定义在类的内部也可以定义在类外
2.this
1)this形参是隐式定义的,因此任何自定义名为 this 的参数或变量的行为都是违法的
2)可以在成员函数体内部使用 this
3)this 总是指向“这个”对象,保存对象的地址,任何对类成员的直接访问都被看作 this 的隐式引用,因此 this 是一个常量指针,不允许改变 this 中保存的地址
3.引入 const 成员函数:const 的作用是修改隐式 this 指针的类型。
1)默认情况下,this 的类型是指向类类型非常量版本的常量指针,尽管 this 是隐式的,但仍需要遵循初始化规则,因此我们不能把 this 绑定在一个常量对象上
2)若某个成员函数的函数体内不会改变 this 所指的对象,则把 this 设置为指向常量的指针有助于提高函数的灵活性
3)C++中允许将 const 关键字放在成员函数的参数列表之后,此时紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针,像这样使用 const 的成员函数被称作常量成员函数
4)若 this 是指向常量的指针,则常量成员函数不能改变调用它的对象的内容
5)常量对象,以及常量对象的引用或指针都只能调用常量成员函数
4.类作用域和成员函数
1)类本身就是一个作用域,类的成员函数的定义嵌套在类的作用域之内
2)对于类的编译,编译器分两步处理:首先编译成员的声明,然后才轮到成员函数体,因此,成员函数体可以随意使用类中的成员而无需在意这些成员出现的次序(成员可以定义在函数体之后)
5.在类的外部定义成员函数:当在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配,即返回类型、参数列表和函数名都得与类内部的声明保持一致;若成员被声明为常量成员函数,则它的定义也必须在参数列表后明确指定 const 属性;类外部定义的成员的名字必须包含它所述的类名
double Sales_data::avg_price() const{ if(units_sold) return revenue/units_sold; else return 0; }
6.构造函数:
Sales_data() = default;
因为该构造函数不接受任何实参,因此是一个默认构造函数,定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,而需要默认的构造函数。
4)构造函数初始值列表:构造函数初始值列表是成员名字的一个列表,每个名字后面紧跟括号括起来的成员初始值,不同成员的初始化通过逗号分隔开来,当某个数据成员被构造函数初始值列表忽略时,它将以合成默认构造函数相同的方式隐式初始化。
//其他两个成员以合成默认构造函数相同的方式隐式初始化,本例中,由于另两个成员有类内初始值,因此使用类内初始值初始化 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.拷贝、赋值和析构
1)除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为
1.在C++语言中,使用访问说明符加强类的封装性:
1.如果我们提供了构造函数,因此编译器不会再自动生成默认的构造函数,如果我们的类仍需要默认构造函数,则必须显示地声明出来,比如:通过 =default 告诉编译器为我们合成默认的构造函数
2.令成员作为内联函数:常有一些规模较小的函数适合于被声明为内联函数
1)定义在类内部的成员函数时自动 inline 的,称为隐式内联
2)可以在类的内部把 inline 作为声明的一部分显示地声明成员函数
3)也可以在类的外部用 inline 关键字修饰函数的定义
注:虽然无须在声明和定义的地方同时说明 inline,但这么做也是合法的,但最好只在类外部定义的地方说明 inline,这样更易理解
3.可变数据成员:有时我们希望即使在一个 const 成员函数内,也能修改类的某个数据成员,可以通过加 mutable 关键字达到这一点
class Screen{ public: void some_member() const; private: mutable size_t access_ctr; //因为mutable,即使在一个const对象中也能被修改 }; void Screen::some_member() const { ++access_ctr; //保存一个计数值,用于记录成员函数被调用的次数 }
4.类数据成员的初值:类内初始值必须使用 = 的初始化形式或者花括号括起来的直接初始化形式
class Screen{ public: typedef std::string::size_type pos; //... private: pos cursor = 0; //使用 = 的初始化形式 pos height = 0, width = 0; std::string contents; }; class Window_mgr{ private: std::vector<Screen> screens{Screen(24, 80, '')}; //使用花括号括起来的直接初始化形式 };
5.返回 this 的成员函数
1)返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本
2)一个 const 成员函数如果以引用的形式返回 *this 那么它的返回类型将是常量引用*,而该返回值则不可以的调用普通成员函数,因为普通成员函数会修改属性,因此返回的常对象只能调用常函数
3)通过区分成员函数是否是 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: 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); //调用常量版本
6.类类型:
1)每个类定义了唯一的类型,对于两个类来说,即使成员完全一样,这两个类也是两个不同的类型
2)类的声明:就像函数的声明和定义分离开一样,我们也可以只声明类而暂时不定义它
class Screen; //Screen类的声明
这种声明也称为前向声明,但在它声明之后定义之前是一种不完全类型,此时知道 Screen 是一个类类型,但是不清楚到底包含哪些成员
不完全类型只能在非常有限的情景下使用:
struct X{ friend void f() {/* 友元函数可以定义在类的内部*/ } X() {f();} //错误:f还没有被声明 void g(); void h(); }; void X::g() {return f();} void f(); void X::h() {return f();}
上述代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明。
1.作用域和定义在类外部的成员:一个类就是一个作用域,因此在类的外部定义成员函数时必须同时提供类名和函数名;另一方面,函数的返回类型通常出现在函数名之前,因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外,这时返回类型必须指明它是哪个类的成员
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s) { screens.push_back(s); return screens.size() - 1; }
2.名字查找与类的作用域
1)对于定义在类内部的成员函数来说,类的定义分为两步:
typedef double Money; string bal; class Account{ public: Money balance() {return bal;} private: Money bal; //... };
当编译器看到 balance 函数的声明语句时,将在 Account 类的范围内寻找对 Money 的声明。编译器只考虑 Account 中在使用 Money 前出现的声明,因为没找到匹配的成员,所以编译器会接着到 Account 的外层作用域中查找,因此找到了 Money 的 typedef 语句,该类型被用作 balance 函数的返回类型以及 bal 的类型;
另一方面,balance 函数体在整个类可见后才被处理,因此,该函数的 return 语句返回的是名为 bal 的成员,而非外层作用域的 string 对象
3)类型名的定义通常应该出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名定义之后
4)成员函数中使用名字的具体解析方式
//注意:通常情况下,不建议为参数和成员使用同样的名字 int height; //定义了一个名字,稍后在Screen中使用 class Screen{ public: typedef std::string::size_type pos; void dummy_fcn(pos height){ cursor = width * height; //用的是该成员函数的参数 } private: pos cursor = 0; pos height = 0, width = 0; };
对于上述程序,当编译器处理 dummy_fcn 中的乘法表达式时,它首先在函数作用域内查找表达式中用到的名字,函数的参数位于函数作用域内,因此用到的名字 height 是该函数的参数说明
因此,上述程序 height 参数隐藏了同名的成员,如果想绕开上面的查找规则,可以改为下述代码
void Screen::dummy_fcn(pos height){ cursor = width * this->height; //成员height,而非参数height //另外一种表示该成员的方式 cursor = width * Screen::height; //成员height,而非参数height
当然,最好的确保我们使用 height 成员的方法是给参数起个别的名字
//建议的写法:不要把成员名字作为参数或其他局部变量使用 void Screen::dummy_fcn(pos ht){ cursor = width * height; //成员 height }
在上述代码中,编译器无法在 dummy_fcn 函数中找到名字 height,因此编译器会接着在 Screen 类内查找匹配的声明,即使 height 的声明出现在 dummy_fcn 使用它之后,编译器也能正确的解析函数使用的是名为 height 的成员
类作用域之后,在外围的作用域中查找:如果编译器在函数和类的作用域中均没有找到名字,则会接着在外围的作用域中查找
void Screen::dummy_fcn(pos height){ cursor = width * ::height; //显示的使用了全局作用域运算符,因此是全局的height }
1.构造函数的初始值有时必不可少:
1)如果成员是 const 或 引用的话,必须将其初始化,类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化
2)随着构造函数体一开始执行,初始化就完成了,我们初始化 const 或者引用类型的数据成员的唯一机会就是通过构造函数初始值,因此如果成员是 const、引用或者属于某种默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值
class ConstRef{ public: ConstRef(int ii); private: int i; const int ci; int &ri; }; //错误:ci 和 ii必须被初始化 //这个版本是对数据成员执行了赋值操作 ConstRef::ConstRef(int ii) { i = ii; //正确,赋值操作 ci = ii; //错误:不能给const赋值 ri = i; //错误:ri是引用,引用必须被初始化,而该程序中ri没被初始化 } //正确:显示地初始化引用和const成员 ConstRef::ConstRef(int ii):i(ii),ci(ii),ri(i) { }
3)使用构造函数初始值:在很多类中,初始化和赋值的区别事关底层的效率问题,前者直接初始化数据成员,而后者则先初始化再赋值,除了效率问题,一些数据成员必须被初始化,因此建议养成构造函数初始值的习惯,可以避免某些意想不到的编译错误
4)成员初始化顺序:成员的初始化顺序与它们在类的定义中的出现顺序一致,而构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序,因此,最好令构造函数初始值的顺序与成员声明的顺序保持一致,而且如果可能的话,尽量避免使用某些成员初始化其他成员
class X{ int i; int j; public: //错误,i在j之前初始化,而此时j是未定义的 X(int val):j(val), i(j){ } };
2.委托构造函数:一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
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.默认构造函数的作用:当对象被默认初始化或值初始化时,自动执行默认构造函数
1)默认初始化在以下情况下发生:
Sales_data obj(); //错误:声明了一个函数而非对象 Sales_data obj2; //正确:obj2是一个对象而非函数
4.隐式的类类型转换
1)能通过一个实参调用的构造函数定义了一条从构造函数的实参类型向类类型隐式转换的规则,需要多个实参的构造函数不能用于执行隐式转换
string null_book = "9-999-99999-9"; //构造一个临时的Sales_data对象 //该对象的units_sold和revenue等于0,bookNo等于null_book item.combine(null_book);
在上述程序中,编译器用给定的 string 自动创建了一个 Sales_data 对象,新生成的这个临时 Sales_data 对象被传递给 combine,因为 combine 的参数是一个常量引用,因此我们可以给该参数传递一个临时量
2)只允许一步类类型转换:编译器只会自动地执行一步类型转换
//错误:需要用户定义的两种转换 //(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");
3)抑制构造函数定义的隐式转换
class Sales_data{ public: Sales_data() = default; //多个实参的构造函数不能执行隐式转换,因此也不用声明 explicit 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 Sales_data::Sales_data(istream& is) { read(is, *this); }
Sales_data item1(null_book); //正确:直接初始化 //错误:不能将explicit构造函数用于拷贝形式的初始化过程 Sales_data item2 = null_book;
4)为转换显式地使用构造函数
尽管编译器不会将 explicit 的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换
//正确:实参是一个显式构造的Sales_data对象 item.combine(Sales_data(null_book)); //正确:static_cast可以使用explicit的构造函数 item.combine(static_cast<Sales_data>(cin));
5.聚合类(p 266)
6.字面值常量类(p 267)
有时类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持互连
1)声明静态成员
double r; r = Account::rate();
Account ac1; Account *ac2 = &ac1; //调用静态成员函数rate的等价形式 r = ac1.rate(); //通过Account的对象或引用 r = ac2->rate(); //通过指向Account对象的指针