一、默认方法
1.1 总论
这是 CPP 与 Java 的 OOP 最不同的地方。对于 Java ,基本上是没有默认的行为的,因为所有的对象其实都是指针,所以只有被定义过的方法会用到,而没有被定义过的方法,我们一点也不担心它会在什么其他地方出现,比如:
Student cnx; Student qs = cnx; System.out.println(qs); classroom.answerQuestion(qs) ... end of execution ``` 但是对于 CPP 来说,这些就都是问题了。 ```cpp Student cnx; Student qs = cnx; string s = qs; answerQuestion(qs) ... end of execution
可以看到正是因为 CPP 提供了强大的功能,导致在我们没有给我们的类进行任何设计的时候,给人的感觉就是完全不知道会发生什么?当然程序不会有任何二义性的东西存在,它一定会按照一定的规则去执行的。只不过这个规则被 CPP 隐藏起来了,导致我们入门的时候对于各种东西都十分迷茫。
与之相对的 Java,一是功能不那么强大,比如没有重载运算符之类的骚操作。一个是代码一般都可以溯源,比如来自 Object 之类的。总之让人还比较安心。
这一章讨论的就是 CPP 提供的默认方法。这些方法有一个特点,就是一旦我们自己定义了可以实现相似功能的方法,这些方法就会消失不见。
1.2 默认构造函数
当我们啥啥都没有定义的时候,CPP 会给我们提供一个默认构造函数,这个函数可以帮助我们让语句运行起来,比如下面的语句
#include <iostream> using namespace std;class Node { private : int x, y; public : void show () ; }; void Node::show () { cout << x << " " << y << endl; } int main () { Node a; a.show (); return 0 ; }
虽然没有构造函数,但是是可以运行的。
系统提供的默认构造函数大概长这样,大致意思就是啥都不干。
并不是只有定义了一个无参构造器这个默认构造器才会失效,而是只要定义了一个构造器,这个构造器就会失效,比如加上这样的一个构造器,上面的代码就运行不了。
Node::Node (int xx, int yy) { x = xx; y = yy; }
1.3 默认析构函数
因为 CPP 没有 JVM 的自动回收对象的机制,所以对于 new 出的对象,他是没有办法回收的,所以我们需要手动的定义一个析构函数,方便在 delete 的时候调用。系统会默认提供一个,就是下面这样
同样啥都不干,这个其实已经很好了,因为对于一般的类,只要不在构造的时候 new,这个默认的构造器就已经足够了。
1.4 复制构造函数和赋值运算符
因为我们会有这种操作
Student qs; Student cnx = qs;
所以这种 = 号的行为是怎么定义的?有人会说,那我不用不就得了,但是有的时候虽然可以避免使用等于号,但是架不住一不小心传参啊
void answerQuestion (Student s) { s.show (); } answerQuestion (qs);
我们知道其实是函数传参发生了一个这样的过程
也就类似于复制了,所以必须定义这种东西的行为,这种函数的声明如下
Node Node::Node (const Node &n) ;
但是具体的内容我不知道咋写,实现的功能就是一个 “浅复制”,即数据成员都可以复制,但是如果数据成员是指针,那么指针指向的内容并不会被复制,但是数组会被完全拷贝。赋值运算符同理。
二、基本操作
2.1 定义一个对象
Node a; Node a = Node (); Node *a = new Node (); Node a (x, y) ;Node a = Node (x, y); Node *a = new Node (x, y);
2.2 类的结构
对于 CPP 的一个类,一般是一个头文件放类的声明,一个放类的实现。类的定义包括数据成员的声明(同时也是定义,但是不允许初始化,除非是枚举类或者常量)和方法的声明。
class Node { private : int x, y; public : Node (int xx); Node (char *s); void show () ; }; Node::Node (int xx) { x = xx; y = xx; } Node::Node (char *s) { x = strlen (s); y = -strlen (s); } void Node::show () { cout << x << " " << y << endl; }
2.3 类的静态量和常量
在类中有两种定义常量的方法,都十分方便,如下所示:
class Node { private : const int num = 1 ; enum {aa = 2 , bb = 4 }; int x, y; public : Node (int xx); Node (char *s); void show () ; }; void Node::show () { cout << x << " " << y << " " << num << " " << aa << " " << bb << endl; }
对于静态量,即可以被类的所有对象共享的变量(用 static 修饰)。静态值是不可以在类声明中初始化的,这是因为类声明只是描述了怎样分配内存,但是并没有实际分配内存。所以是没有办法给静态值初始化的,所以需要在外部给他进行初始化
class Node { private : static int num; enum {aa = 2 , bb = 4 }; int x, y; public : Node (int xx); Node (char *s); void show () ; }; int Node::num = 1 ;
注意在外部进行定义的时候就不需要 static 关键字了。
2.4 公有继承
公有继承跟 Java 中的继承是类似的,具体的语法如下
class DerivedClass : public BaseClass{ ... }
因为要先创造基类对象,然后再创造子类对象,所以需要用初始化列表来指定基类的构造器。
在具体的使用方面,私有的数据成员是无法使用的,公有的方法是可以使用的,跟 Java 类似。
2.5 动态绑定
这个也与 Java 相似,基类的指针和引用都可以指向子类。所以当这种指针或者引用调用方法的时候,我们就需要区分到底是用的是子类的方法还是基类的方法。对于 Java 而言,如果指向的对象真的是子类的话,那么就一定会用子类的方法。但是对于 C++,这是一个有待商榷的事情。
只有当方法被声明成了 virtual 虚方法,才具有这种性质。virtual 关键字需要出现在父类和子类的方法声明中,只有都出现以后动态绑定直至才会发生作用。至于为什么这么麻烦,是因为维持动态绑定机制需要在对象内部维护一张虚函数表 ,这是一个很低效的事情,所以 CPP 选择了让用户自定义。
一般析构函数是虚方法,这是因为如果一个父类指针指向了子类对象,而析构方法使用的是父类析构,那么属于子类的那部分就没有办法释放了。
2.6 抽象类
抽象类是无法被实例化的类,他只能作为父类被继承然后实例化。导致它无法实例化的原因是他含有没有被定义的方法。这类方法被称为纯虚方法,需要用 =0 去声明。
具体放一个示例,里面涉及了动态绑定机制和抽象类
brass.h
#ifndef _BRASS_H_ #define _BRASS_H_ #include <iostream> #include <string> class AcctABC { private : std::string fullName; long acctNum; double balance; protected : struct Formatting { std::ios_base::fmtflags flag; std::streamsize pr; }; const std::string &FullName () const {return fullName;} long AcctNum () const {return acctNum;} Formatting SetFormat () const ; void Restore (Formatting &f) const ; public : AcctABC (const std::string &s = "Nullbody" , long an = -1 , double bal = 0.0 ); void Deposit (double amt) ; virtual void Withdraw (double amt) =0 ; double Balance () const {return balance;} virtual void ViewAcct () const =0 ; virtual ~AcctABC () {}; }; class Brass : public AcctABC{ public : Brass (const std::string &s = "Nullbody" , long an = -1 , double bal = 0.0 ) : AcctABC (s, an, bal) {} virtual void Withdraw (double amt) ; virtual void ViewAcct () const ; virtual ~Brass () {} }; class BrassPlus : public AcctABC{ private : double maxLoan; double rate; double owesBank; public : BrassPlus (const std::string &s = "Nullbody" , long an = -1 , double bal = 0.0 , double ml = 500 , double r = 0.10 ); BrassPlus (const Brass &ba, double m1 = 500 , double r = 0.10 ); virtual void ViewAcct () const ; virtual void Withdraw (double amt) ; void ResetMax (double m) {maxLoan = m;} void ResetRate (double r) {rate = r;} void ResetOwes () {owesBank = 0 ;} }; #endif
brass.cpp
#include <iostream> #include "brass.h" using std::cout;using std::endl;using std::string;using std::ios_base;typedef std::ios_base::fmtflags format;typedef std::streamsize precis;format setFormat () ;void restore (format f, precis p) ;AcctABC::AcctABC (const string &s, long an, double bal) { fullName = s; acctNum = an; balance = bal; } void AcctABC::Deposit (double amt) { if (amt < 0 ) { cout << "Negative deposit not allowed; " << "deposit is canceled\n" ; } else { balance += amt; } } void AcctABC::Withdraw (double amt) { balance -= amt; } AcctABC::Formatting AcctABC::SetFormat () const { Formatting f; f.flag = cout.setf (ios_base::fixed, ios_base::floatfield); f.pr = cout.precision (2 ); return f; } void AcctABC::Restore (Formatting &f) const { cout.setf (f.flag, ios_base::floatfield); cout.precision (f.pr); } void Brass::Withdraw (double amt) { if (amt < 0 ) { cout << "Withdrawal amount must be positive.\n" ; } else if (amt <= Balance ()) { AcctABC::Withdraw (amt); } else { cout << "Withdrawal amount of $ " << amt << " exceeds your balance.\n" ; } } void Brass::ViewAcct () const { Formatting f = SetFormat (); cout << "Brass Client: " << FullName () << endl; cout << "Account Number: " << AcctNum () << endl; cout << "Balance $" << Balance () << endl; Restore (f); } BrassPlus::BrassPlus (const std::string &s, long an, double bal, double ml, double r) : AcctABC (s, an, bal) { maxLoan = ml; owesBank = 0.0 ; rate = r; } BrassPlus::BrassPlus (const Brass &ba, double ml, double r) : AcctABC (ba) { maxLoan = ml; owesBank = 0.0 ; rate = r; } void BrassPlus::ViewAcct () const { Formatting f = SetFormat (); cout << "BrassPlus Client: " << FullName () << endl; cout << "Accout Number: " << AcctNum () << endl; cout << "Balance: $" << Balance () << endl; cout << "Maximum loan: $" << maxLoan << endl; cout << "Owed to bank: $" << owesBank << endl; cout.precision (3 ); cout << "Loan Rate: " << 100 * rate << "%\n" ; Restore (f); } void BrassPlus::Withdraw (double amt) { Formatting f = SetFormat (); double bal = Balance (); if (amt <= bal) { AcctABC::Withdraw (amt); } else if (amt <= bal + maxLoan - owesBank) { double advanced = amt - bal; owesBank += advanced * (1.0 + rate); cout << "Bank advance: $" << advanced << endl; cout << "Finance charge: $" << advanced * rate << endl; Deposit (advanced); AcctABC::Withdraw (amt); } else { cout << "Withdrawal amount of $ " << amt << " exceeds your balance.\n" ; } }
main.cpp
#include <iostream> #include <string> #include "brass.h" const int CLIENTS = 4 ;int main (int argc, char ** argv) { using std::cin; using std::cout; using std::endl; AcctABC *p_clients[CLIENTS]; std::string tmp; long tmpNum; double tmpBal; char kind; for (int i = 0 ; i < CLIENTS; i++) { cout << "Enter client's name: " ; getline (cin, tmp); cout << "Enter client's account number: " ; cin >> tmpNum; cout << "Enter opening balance: $" ; cin >> tmpBal; cout << "Enter 1 fo Brass Account or 2 for BrassPlus Account: " ; while (cin >> kind && (kind != '1' && kind != '2' )) { cout << "Enter either 1 or 2: " ; } if (kind == '1' ) { p_clients[i] = new Brass (tmp, tmpNum, tmpBal); } else { double tmax, trate; cout << "Enter the overdraft limit: $" ; cin >> tmax; cout << "Enter the interest rate as a decimal fraction: " ; cin >> trate; p_clients[i] = new BrassPlus (tmp, tmpNum, tmpBal, tmax, trate); } while (cin.get () != '\n' ) { continue ; } } cout << endl; for (int i = 0 ; i < CLIENTS; i++) { p_clients[i]->ViewAcct (); cout << endl; } for (int i = 0 ; i < CLIENTS; i++) { delete p_clients[i]; } cout << "Done.\n" ; return 0 ; }
三、特点操作
3.1 类型转换
这个是个很神奇的地方,就是基本上什么类型都可以给对象赋值,只要定义了相关的单参量的构造函数。大概这样
Node::Node (int xx) { x = xx; y = xx; } Node a = 2 ;
这就能跑了,而且跑起来还贼对。最离谱的还有这样的
Node::Node (char *s) { x = strlen (s); y = -strlen (s); } Node a = "Hello, world." ;
可以看到,基本上只要是有定义,就可以跑。
3.2 初始化列表
对于构造函数,其实现开辟一块内存空间,然后再执行构造函数,所以其实对于每个数据成员,构造函数的赋值都不是初始化。如果是这样的话,那么如果数据成员有 const 常量的话,就会报错
class Node { private : static int num; enum {aa = 2 , bb = 4 }; const int x, y; public : Node (int xx); Node (char *s); void show () ; }; Node::Node (int xx) { x = xx; y = xx; }
所以就需要初始化列表来辅助,把构造函数改成这样
Node::Node (int xx) :x (xx), y (xx) {}
初始化列表就是在开辟内存空间的时候进行的一次初始化,所以是可以的,我们即使不使用 const,也尽量在初始化列表中初始化数据成员,因为比较高效。
3.3 内联函数
在类声明中定义的函数会被自动设置成内联函数,一般的 getter 和 setter 都是内联函数。
这个特性是十分有必要的,类声明在头文件中,如果不是内联函数,很有可能出现定义多次的情况。
3.4 运算符重载
其实运算符可以看做一种函数,只要稍微有点离散基础的人,都可以很好的理解这件事情。所以就不细讲了。只说一下声明格式
returnType operator op (argument-list) ;
当对象使用重载运算符的时候,其实就是在使用第二行这个函数。
object op arguments; ogject.op (arguments);
3.5 友元函数
上面的运算符有一个问题,就是对于二元运算符,它要求对象一定是前面那个运算数。《C++ Primer Plus》中讲到一个有意思的东西,对于时间,我们定义一个乘法重载
Time operator *(double n) const ;
然后就会发生一个滑稽的事情,就是不再满足乘法分配律
所以没有办法,我们只能再定义一个友元函数来改这个疏忽。所谓的友元函数就是对于私有的数据成员依然具有可见性的函数。他们同样需要在类声明中声明,并用关键词 friend 修饰 ,示例如下
class Time { private : int hours; int minutes; public : Time (); Time (int h, int m = 0 ); void AddMin (int m) ; void AddHr (int h) ; void Reset (int h = 0 , int m = 0 ) ; Time operator +(const Time & t) const ; void Show () const ; Time operator *(double n) const ; friend Time operator *(double m, const Time & t); friend std::ostream & operator <<(std::ostream & os, const Time & t); };
具体的定义如下
Time operator *(double mult, const Time & t) { Time result; long totalminutes = t.hours * mult * 60 + t.minutes * mult; result.hours = totalminutes / 60 ; result.minutes = totalminutes % 60 ; return result; }
可以看到它是可以使用类内部的数据成员的。
3.6 const 方法
对于一个 const 的对象,使用方法的时候是需要仔细考量的,比如说对于一个打印方法
void Node::show () { cout << x << " " << y << " " << num << " " << aa << " " << bb << endl; }
即使没有修改数据成员的值,但是在执行下面语句的时候,依然是会报错的
const Node a = 3 ;a.show ();
这是很好理解的,在前面对 const 的介绍中,我们指出,不是真的没修改就没问题,而是我们必须显式的声明不会修改。所以我们得操作一下。但是问题就来了,因为对象的数据成员是隐式 调用的,我们没有显式的传参,所以没有参数给咱们 const 修饰。
所以最后我们的处理办法是在函数的结尾写一个 const。注意声明处和定义处都是需要书写的
void show () const ; void Node::show () const { cout << x << " " << y << " " << num << " " << aa << " " << bb << endl; }
3.7 组合、私有继承、保护继承
如果想要讨论私有继承,首先先补充一些访问权限的知识,CPP 中访问权限分为 3 种,而访问场景可以分为 4 种,列表如下:
种类 / 情形
类内访问
友元访问
子类内访问
实例访问
public
yes
yes
yes
yes
protected
yes
yes
yes
no
private
yes
yes
no
no
也就是说,权限并不限制类内访问和友元访问 这两种形式,只有 public 可实现实例访问,protected 不能被实例访问,所以一般在类内的 typedef 和 friend 会用 protected 修饰,因为这些声明本身也不应当通过实例访问。
我们知道权限控制的对象有两个,一个是数据,另一个是方法,对于这一节有用的是对于方法的权限控制,如果一个方法是 public 的,那么就意味着我们可以通过它的实例调用这个方法,其本质是这个实例承担了这个 public 方法对应的责任 。权限控制的本质是责任的控制。而如果一个方法是 private 的,那么这个方法并不意味着一种责任(这就好像白大褂要是穿到街上,那么就意味着救死扶伤的责任,而只在家里穿,未尝不是一种 cosplay )。而当我们谈到继承的时候,衍生类如果和基类保持一种 is-a 的关系,那么就意味着,衍生类继承了基类的责任。
而 3 种继承的本质是 “在衍生类中修改或者保持基类权限”,这种修改的是 “以严为准的”,如下所示:
继承方式 / 基类权限
public
protected
private
public
public
protected
private
protected
protected
protected
private
private
private
private
private
表格中的内容是衍生类继承的数据和方法在衍生类中的权限。
也可以理解为 public 继承并不会改变基类中原有的权限,而 protected, private 继承都会对于原有的权限进行一定的修改。他们会将原本 public 的方法变成严格的权限,也就是说,衍生类和基类间就不再具有 is-a 的关系了。也就是说,“私有继承” 在逻辑上并不是一种 “继承”。事实也正是如此,私有继承并不能让衍生类隐式转换成基类,如下所示:
class Person { ... };class Student : private Person { ... }; void eat (const Person& p) ; Person p; Student s; eat (p); eat (s);
那么 “私有继承” 有什么用呢?我们说私有继承是软件实现 中的概念,与软件设计 无关。它和 “组合” 所表示的 has-a 关系相似但不同,它在实现语义上是一种 is-implemented-in-terms-of ,也就是说 “衍生类的实现和基类很类似”。基类和衍生类可以毫无关系,但是他们在一些责任的实现上是相同的,所以他们有一个私有继承关系。
而在使用上,私有继承的使用方法和组合有一定的对照关系,可以看成是一种 “匿名组合”,比如说调用方法时,有如下差别
component.method () Base::method ()
在使用基类或者组件的时候
this ->componet(Base *) this
此外我们还可以用 public 权限声明搭配 using 语法来使得衍生类实现部分基类的责任:
class Derived : private Base{ public : using Base::baseMethod; } Derived de; de.baseMethod ();
3.8 友元类
在类 A 中指明类 B 能够直接访问类 A 中的 private 和 protected 成员,那么类 B 就是类 A 的友元类。
声明方式如下:
class A { friend class B ; }
需要强调,friend 声明可以在 public, protected, private 任何一个作用域下,没有任何区别。
友元类具有如下性质:
无法被继承,也就是如果 B 是 A 的友元,subB 是 B 的子类,不能推导出 subB 是 A 的友元。
无法被传递,也就是如果 B 是 A 的友元, C 是 B 的友元,不能推导出 C 是 A 的友元。
3.9 内部类
如果一个类定义在另一个类的内部,这个内部的类就叫做内部类。
对于 OuterClass ,其看待 InnerClass 与看待其他类并没有什么区别,并不能通过 OuterClass 的对象来访问 Inner 的数据。不过 OuterClass 可以实例化 InnerClass ,就和实例化其他类一样。在本质上,外部类无非仅仅限定了内部类类名的作用域范围。
对于其他类或者过程,其看待 InnerClass 与其声明位置有关,如果 InnerClass 是 public 的,那么可以用 OuterClass::InnerClass 来实例化一个内部类对象,而如果是 private 的,那么则无法实例化内部对象(只有 OuterClass 可以实例化这样的 InnerClass )
而 InnerClass 是 OuterClass 的友元类,并不需要特殊的 friend 声明。但是外部类不是内部类的友元。
3.10 成员指针
成员指针指的是指向类成员的指针。也就是可以指向类内的某种数据类型 的指针。它只能用于类的非静态成员变量 。
举例来说
class Foo { public : DataPtr dataPtr; FuncPtr funcPtr; int data; void func () ; }; using DataPtr = int (Foo::*);using FuncPtr = void (Foo::*)();int main () { Foo f; DataPtr dataPtr = &Foo::data; FuncPtr funcPtr = &Foo::func; auto pf = &f; std::cout << f.*dataPtr << pf->*dataPtr << std::endl; }
可以看到在类型声明阶段,在原来的类型声明前加上了类名 Foo :
using DataPtr = int (Foo::*);using FuncPtr = void (Foo::*)();
这种类型的指针并不会指向具体的内容,而是会指向某个类内成员的类型,如下所示:
DataPtr dataPtr = &Foo::data; FuncPtr funcPtr = &Foo::func;
正因如此,这种类型的指针在读写其指向的内容的时候,并不能直接使用 *ptr ,而必须与相应的实例相结合才能发挥作用,同时需要使用特殊的运算符 .* 和 ->* 。
std::cout << f.*dataPtr << pf->*dataPtr << std::endl;
从本质来说,成员指针描述了特定成员的位置距离实例的起点有多少个字节 。