Coursera C++ 程序设计,北京大学

简单的看了一下课程内容:介绍了类,内联函数,友元函数,运算符重载,多态,虚函数,\(STL\) 等等。
这些内容之前仅有使用上的接触,并没真正理解过。需要用心学一下!
(但是感觉郭炜老师讲的没李戈老师那么有趣) <- (我错了类真是挺好玩的) <- (郭老师讲的真好,就是 a bit nerd,第九周过后都懂)


函数指针

之前学习 \(SML\) 时,一直觉得将函数以参数形式传入另一个函数这个功能很强大:C++ 中原来是靠函数指针实现的
一般的,函数的名字即为指向该函数的函数指针,我们也可以自定义指向某个函数的函数指针,格式为 函数指针名(实参表);
本质上来讲,每个函数在内存中也会占用一段空间,函数指针即指向这个函数所占内存的其实地址。所以,对函数指针进行的自加自减操作是没有意义的

bool cmp(int x, int y) {
  return x > y;
}
bool (*pf)(int, int);     // 声明一个函数指针:注意声明的格式!与 cmp 函数的类型以及参数类型完全一致
pf = cmp;
sort(a + 1, a + n + a, pf);  // 向其他函数:这里以 sort 为例传入自定义的 cmp 函数

命令行参数

在 cmd 中使用 C++ 运行命令行时,可以通过在 main 函数中设置参数来处理命令行参数

int main(int argc, char *argv[]) {
  ...
}

argc: 启动程序时,命令行参数的个数:由于可执行文件名也算作一个命令行参数,所以 argc 至少是 \(1\)
argv[]: 注意这是一个指针数组,每个元素都是 char 类型的指针,指向某个参数字符串。也可以说是 char** 类型。
char (*argv)[] 区别开,后者指的是指向某个 char 一维数组的指针。


位运算

&, |, ~, ^, >>, <<
六个按位运算符,之前都已经很熟悉,所以就不具体做笔记了
几个需要注意的点:>> 右移一位相当于原来的值除以 \(2\) 向小取值。所以
\(13>>1=6\)\(-13>>1=-7\)
左移 << 高位舍弃,低位补 \(0\)
右移 >> 低位舍弃,高位为 \(0\) 则补 \(0\),为 \(1\) 则补 \(1\)


引用

我们用格式 类型名 & 引用名 = 某变量名

int n = 3;
int &r = n;  // 定义了引用 r 引用了 n,r 的类型是 int &
r = 4;       // 同时 n 的值也变为 4

某个变量的引用,等价于这个变量,相当于这个变量的一个别名(alias)
定义引用时一定要将其初始化为引用某个变量,并且引用一定要引用某个变量,不能引用常量和表达式

int n = 30;   // 全局变量 n = 30
int &Setvalue() { return n; }  // 该函数返回一个引用,引用变量 n
Setvalue() = 40;

以上的函数定义了一个引用作为函数的返回值:这样做的好处是函数可以写在赋值表达式的左侧

int n = 20;
const int &r = n;  // 定义一个常引用
r = 30;   // wrong!
n = 30;   

在定义引用时,前面加 const 关键词即可定义一个常引用,如以上代码,\(r\) 即是一个对 \(n\) 的常引用
常引用和非常引用的区别:我们无法通过常引用去修改其引用的内容!
关于常引用和非常引用的转换:const T&T& 是两种不同的类型
可以通过 T&T 类型 来初始化 const T& 类型
const Tconst T& 类型则不能用来初始化 T& 类型的引用,除非进行 强制类型转化


const关键字与常量

  1. 定义常量
const int MAX_N = 100;      // 整形常量
const double Pi = 3.14;     // 浮点型常量
const char *SCHOOL_NAME = "Hong Kong University";   // 字符串常量
  1. 定义常量指针
int n, m;
const int *p = &n;
*p = 5; // Wrong!

不可通过常量指针来修改其指向的内容:函数参数为常量指针时,可避免函数内部不小心改变指针所指的内容的情况
不能将常量指针赋给非常量指针,反之可以
3. 定义常引用

int n, m;
const int &r = n;
r = 3;  // Wrong!

不可通过常引用修改其引用的变量


动态内存分配

new 关键字实现动态内存分配

  • 分配一个变量
int *p = new int; // 格式:P = new T, P 为一个类型为 T* 的指针
delete p;        // 格式:delete P, 使用完之后释放内存

动态分配一片大小为 sizeof T 字节的内存空间且将其内存空间的开头赋给 P

  • 分配一个数组
int *p = new int[100]; //  格式:P = new T[N], P 同样是一个类型为 T* 的指针
delete []p;            //  格式:delete []P

动态分配一个大小为 N * sizeof T 字节的内存空间并将其内存空间的开头赋给 P
注意:使用 new 动态分配的内存空间使用完毕后一定要通过 delete 释放


内联函数

经常用的 inline 大法哈哈哈
编译器处理内联函数时与普通函数不同,会将内联函数的代码插入到调入语句处而不会产生调用函数的语句,因此速度更快
于是我们可以在需要经常被调用并且代码比较简短的函数前加上 inline 关键字以提升代码运行速度


重载函数

特性:同名函数可以根据参数表的不同而被区别开来:编译器通过调用语句中实参的个数与类型判断决定调用哪个函数

int Max(int x, int y) {...}           // 1
double Max(double x, double y) {...}  // 2
int Max(int x, int y, int z) {...}    // 3
Max(1, 2, 3);    // 编译器能判断你要调用的是 3
Max(1.12, 2.3);  // 2
Max(1, 2.3);     // Wrong!

函数缺省参数

C++ 定义参数时,可以让最右边的连续若干个参数有缺省值,在调用时,若相应位置不写参数,参数即为缺省值

void func(int x, int y = 2, int z = 3) { ... }
func(1);    // 即 func(1, 2, 3)
func(1, 8); // 即 func(1, 8, 3)
func(1, , 8); // Wrong! 只能最右边的连续若干个参数缺省

类是面向对象程序设计实现信息封装的基础。每个类包含数据说明和一组操作数据或传递消息的函数。类的实例称为对象

  • 将客观事物抽象成类
  • 对象:
    类定义的变量 -> 类的实例 -> 对象
    对象的大小:所有成员变量大小之和
    对象之间可以用 = 进行赋值,不能用 >,<,==,!=等运算符进行比较(除非进行运算符重载)
    访问对象中成员(成员变量即成员函数)的三种方法:对象.成员名,引用名.成员名,对象指针->成员名
  • 类定义:
class CLASS_NAME {
  public:
    int a, ...   // 公共成员变量
    void init() { ... }  // 公共成员函数
    void func(int, int); // 声明一个公共成员函数,函数体可在类定义之外实现
  private:
    int a2, ...   // 私有成员变量
    void initw() { ... } // 私有成员函数
  protected:
     // 指定保护成员
};
void CLASS_NAME::func(int x, int y) { ... }  // 在类的外部定义成员函数
  • 类成员的可访问范围
    分为三类 public, private, protected
    类的成员函数内部可以访问当前以及同类对象的所有属性以及函数
    外部只能访问同类对象的公有成员
    注意,缺省默认为私有成员
class CLASS_NAME {
  int n, m;            // 未声明,默认为私有成员
  void init() { ... }  // 同上
  public:
    ...
}

设置私有成员的目的:要求对成员的访问必须通过成员函数来进行,设置私有成员的机制 -> 隐藏
联想到之前学习 \(SML\) 语言中 signature 对某些变量与函数的隐藏机制


内联成员函数与重载成员函数

class CLASS_NAME {
  int x;
  public:
    inline void init(int, int);   // 内联成员函数 1
    void init2(int, int) { ... }  // 内联成员函数 2
};

void CLASS_NAME::init(int x, int y) { ... }   

类中的成员函数在两种情况下是内联成员函数:

  1. 整个函数体的定义都在类的内部
  2. 虽然函数体定义在外,但在类中声明该函数时添加了 inline 关键字
class CLASS_NAME {
  int x, y;
  public:
    void init(int vx = 0, int vy = 0) { x = vx, y = vy; }
    void valX(int v) { x = v; }
    int valX() { return x; }     // 以上两个函数构成了重载关系
};
CLASS_NAME loc;
loc.init(5);     // 存在参数缺省,因此 loc.y 被缺省值 0 赋值
loc.valX(3);
cout << loc.valX() << endl;

同样的,在使用缺省与重载函数时,要注意避免函数产生二义性


构造函数 (重要!)

构造函数是一种成员函数,作用是对对象进行初始化(如为成员变量赋初值)
如果在定义类时没写构造函数,编译器会生成一个默认的无参构造函数 -> 该构造函数仅是形式上的,不做任何操作
构造函数的格式:名字与类名相同,可以有参数,不能有返回值(void 都不行)

class Complex {  //自定义的复数类
  int real, imag;
  public:
    Complex(int x, int y);
    Complex(Complex a, Complex b);    // 有重载关系的两个构造函数
};
Complex::Complex(int x, int y) { real = x, imag = y; }
Complex::Complex(Complex x, Complex y) { real = x.real + y.real, imag = x.imag + y.imag; }
Complex a;                  // Wrong! 由于有自定义的构造函数,无参的构造函数将不会生成
Complex a(3, 5), b(4, 2);   // 自动匹配第一个构造函数
Complex c(a, b);            // 自动匹配第二个构造函数
Complex *p[2] = {new Complex(1, 2), new Complex(a, c)};

一个类可以有多个构造函数(联系重载),构造函数在对象生成时自动被调用,一旦对象生成,就不能再执行构造函数


复制构造函数 (Copy Constructor)

复制构造函数也是一种成员函数,作用一般是实现从源对象到目标对象逐个字节的复制,即使得目标对象的每个成员变量都变得和源对象相等
如果在定义时没写复制构造函数,编译器会生成一个默认的复制构造函数,作用仅为复制
复制构造函数的格式:X::X(X &) 或 X::X(const X &),仅有一个参数,即为对同类对象的引用。加 const 关键字可以实现对同类型常量的引用。

class Complex {
  int real, imag;
};
Complex a;       // 调用默认无参构造函数
Complex b(a);    // 调用默认复制构造函数,将 a 复制给 b 实现对 b 的初始化

复制构造函数起作用的三种情况:

class Complex {
  int real, imag;
  public:
    Complex() {}  // 自定义一个无参构造函数
    Complex(const Complex & c) {
      real = c.real, imag = c.imag;
      cout << "Copy constructor called!" << endl;
    }             // 自定义一个复制构造函数
};

Complex func(Complex C1) { // 第二种情况,某函数有一个参数是类 A 的对象,那么该函数被调用时,类 A 的复制构造函数将被调用
  return C1;               // 第三种情况,某函数的返回值是类 A 的对象,那么该函数再返回时,类 A 的复制构造函数将被调用
}

Complex a;            // 调用自定义的无参构造函数
Complex b(a);         // 第一种情况,利用别的对象初始化新对象
Complex c = a;        // 同样是第一种情况,这里不是赋值符号,而是初始化
Complex d = func(a);  

类型转换构造函数

先来看一个例子:

int a = 6;
a = 7.5 + a;

在以上的程序中,\(7.5\) double型 与 \(a\) int型类型不同,却可以通过编译并完成计算。这是因为doubleint间存在类型转换规则。
在求解表达式时,先将 a 转换为 double 类型,然后与 7.5 相加,得到和为 13.5。在向整型变量 a 赋值时,将 13.5 转换为整数 13,然后赋给 a。
整个过程中,我们并没有告诉编译器如何去做,编译器使用内置的规则完成数据类型的转换。

然而,在我们自定义的类中却不能完成相似的操作:

class Complex {
  int real, imag;
  public:
    Complex(int r, int i) { real = r, imag = i; }  // 构造函数
    Complex(const Complex &c) { real = c.real, imag = c.imag; }  // 复制构造函数
};
Complex a = 9;  // Wrong! 但我们期望这一语句能够顺利执行,对应的复数是 9 + 0i
Complex a = (Complex)9  // 进行强制类型转换?也不行

以上的两个语句无法通过过编译是因为编译器并不知道这一转换规则。为了解决这个问题,我们需要自定义类的类型转换构造函数!
转换构造函数也是构造函数的一种,它除了可以用来将其它类型转换为当前类类型,还可以用来初始化对象,这是构造函数本来的意义。
进行数学运算、赋值、拷贝等操作时,如果遇到类型不兼容的情况时,编译器会检索当前的类是否定义了转换构造函数,如果没有定义的话就转换失败,如果定义了的话就调用转换构造函数。
类型转换构造函数的格式:T1(T2)。只有一个参数且参数类型与当前类不同,在函数定义内实现转化

class Complex {
  int real, imag;
  public:
    Complex(int r, int i) {  real = r, imag = i; }
    Complex(const Complex &c) { real = c.real, imag = c.imag; }
    Complex(int i) { real = i, imag = 0; }      // 自定义的 int -> complex 类型转化函数
};
Complex a = 9; // 成功!注意这里的 = 是初始化的含义。创建一个临时 complex 对象转换 9
a = 12;        // 成功!

析构函数

与构造函数相对的,析构函数用于销毁对象时处理后续工作。
其没有参数与返回值,不需要进行显式调用,而是在销毁对象时自动执行一个类只有一个析构函数
若未自定义析构函数,则编译器会生成一个默认的缺省析构函数。
析构函数的格式:无参数,无返回值,名字与类名一致,唯一与无参构造函数的区别是前面有一个符号 ~

class String {
  char *p;
  public:
    String() { p = new char[10]; }  // 构造函数
    ~String();
};
String::~String() { delete []p; }   // 析构函数释放内存

析构函数调用的时机:
对于全局与局部对象,在对象离开其作用域后会执行析构函数
对于 new 创建的对象,只有通过 delete 删除时才会调用析构函数;如果没有 delete,析构函数就不会被执行。

静态成员变量与静态成员函数

本质上是被封装在类中的全局变量与全局函数,其可视为这个类的一个性质,被所有对象所共享;而即使没有一个对象,也可以调用它们。
在静态成员函数中调用非静态成员变量与函数是无意义的。
加上 static 关键字来创建静态成员变量与静态成员函数,并通过 类名::静态成员名 来进行访问或调用。
静态成员变量必须初始化,而且只能在类体外进行

class Rectangle {
  int r, c;          
  static int totalArea;   // 定义了一个静态成员变量总面积,用于记录当前所有矩形的面积之和
  public:
    Rectangle(int rr, int cc) {
      r = rr, c = cc;
      totalArea += rr * cc;  // 维护总面积
    }
    Rectangle(Rectangle &a) {
      r = a.r, c = a.c;
      totalArea += a.r * a.c; // 维护总面积
    } 
    ~Rectangle() {
      totalArea -= r * c;     // 析构时维护总面积
    }
    static void print() {
      cout << totalArea << endl;
      cout << r << " " << c << endl;  // Wrong! 在静态成员函数中访问非静态成员变量使没有意义的
    }
};
int Rectangle::totalArea = 0;  // 静态成员变量必须在类的外部进行初始化
int main() {
    Rectangle a(2, 3), b(3, 4);
    Rectangle c = a;
    Rectangle::print();        // 调用一个静态成员函数
    return 0;
}

成员对象与封闭类

成员对象:一个类的成员变量是另一个类的对象
封闭类;包含成员对象的类叫做封闭类
初始化列表:构造函数(参数表):成员变量1(参数1), 成员变量2(参数2), .... {}

class CTyre {
  int radius;
  int width;
  public:
    Ctyre(int r, int w): radius(r), width(w) {} // 初始化列表的方式进行初始化
};
class CEngine {
  int cap;
  public:
    CEngine(int c) : cap(c) {}
};
class CCar {
  int price;
  Ctyre tyre;   
  CEngine engine;   // 成员变量中包含其他类的对象!CCar是一个封闭类
  public:
    CCar(int pri, int r, int w, int c) : price(pri), tyre(r, w), engine(c) {}  // 封闭类的初始化:也采用初始化列表
};

当封闭类对象生成时,先执行所有成员对象的构造函数再执行封闭类对象的构造函数
反之,当封闭类对象销毁时,先执行封闭类对象的析构函数再执行所有成员对象的析构函数


友元

友元函数与友元类:
一个类的友元函数定义在类外部,可以访问该类的私有成员

class CCar;    // 提前声明
class CDriver {
  public:
    void modifyCar(CCar * pcar);  // 改装汽车
};
class CCar {
  private:
    int price; 
  friend void CDriver::modifyCar(CCar *pcar);    // 声明友元函数1:CDriver的成员函数 modifyCar
  friend int MostExpensiveCar(CCar cars[]);      // 声明友元函数2:外部的函数 MostExpensiveCar
  // 以上两个函数均需要访问 CCar 的私有成员 price
};

友元类:
一个类的友元类中的所有成员函数均能访问该类的私有成员

class CCar {
  private:
    int price;
  friend class CDriver;   // 声明 CDriver 是 CCar 的友元类,CDriver 的所有成员变量均能访问 CCar 的私有成员
};

友元类之间的关系不能传递与继承。


this 指针

this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。
不过 this 这个形参是隐式的,它并不出现在代码中,而是在编译阶段由编译器默默地将它添加到参数列表中。
只有非静态函数中可以使用 this 指针,因为静态函数并不是作用于某个特定的对象的

class Complex {
  int real, imag;
  public:
    Complex(int r, int i) : real(r), imag(i) {}
    Complex(const Complex &c) { real = c.real, imag = c.imag; }
    Complex(int x) { real = x, imag = 0; }
        
    Complex ReturnSelf() { return *this; }   // this 指针指向执行这个函数的对象,所以 return *this 即返回调用这个函数的对象本身
    void print() {
      cout << "real:" << this->real << endl; // = cout << "real:" << real << endl;
      cout << "imag:" << this->imag << endl; // = cout << "imag:" << imag << endl;  
    }
    ~Complex()
}

常量对象,常量成员函数与常引用

  1. 常量对象:
    如果不希望某个对象的值被改变,则在定义该对象时在前面加上 const 关键字使其成为常量对象
  2. 常量成员函数:
    在类的成员函数说明加入 const 关键字使之成为常量成员函数。常量成员函数执行期间不应修改其所作用的对象
    (静态成员变量除外,因为严格来说它并不属于任何一个对象)
class Sample {
  public:
    int value;
    void GetValue() const;  // 注意 const 是标记在函数说明后的
    void func() {};
    Sample(){}
};
void Sample::GetValue() const {
  value = 0; // Wrong! 常量成员函数不能对对象进行修改
  func();    // Wrong! 常量成员函数不能调用非常量成员函数,因为这样会有改变对象的风险
}
int main() {
  const Sample o;     // 定义一个常量对象
  o.value = 3;        // Wrong! 常量对象不可被更改
  o.func();           // Wrong! 常量对象不可调用非常量成员函数
  o.GetValue();       // 这是允许的,常量对象可以调用常量成员函数
}

注意,具有同样的名称和参数表成员函数,但一个是常量成员函数一个是非常量成员函数,这样的两个函数是可以构成重载关系的

  1. 常引用:
    之前介绍过,在引用前加 const 关键字可以避免改变形参对实参的影响

重载运算符

这个之前用的很多,本质目的是:拓展 C++ 中提供的运算符的适用范围,以用于类所表示的抽象数据类型
运算符可以重载为普通函数与类的成员函数。重载为普通函数时,参数个数为运算符目数
而重载为成员函数时,由于对象的存在,参数个数为运算符目数减一

  • 重载赋值运算符 =
    重载赋值运算符后,赋值运算符两边的类型可以不匹配 (将 int 赋给自定义 complex 对象,将 char* 字符串赋给自定义 string 对象)
    注意:赋值运算符 = 只能重载为成员函数!
class string {
  char *str;
public:
  string() : str(NULL) {}    // 构造函数初始化 str 为空指针
  const char * c_str() { return str; }    // 返回该指针,但限制外部不可通过其改变指向的内容
  char* operator = (const char* s);
  ~string();
};
char* string::operator = (const char* s) {
  if (str)  delete []str;
   if (s) {
     str = new char[strlen(s) + 1];   // +1 是为了放 \0
     strcpy(str, s);
   } else
     str = NULL;
   return str;
}
string::~string() {
  if (str)  delete []str;
}
int main() { 
  string a = "Hi";   // Wrong!这里的 = 是初始化而不是赋值语句
  string b;
  b = "AvavaAva";    // 在重载运算符后正确
}

这样重载可以实现将字符串赋给自定义 string 类型的操作
我们知道,同类之间的对象可以直接使用 = 进行赋值,然而这样的赋值是浅拷贝,当对象中有指针成员变量时,浅拷贝会产生很多问题
所以此时我们也需要重载赋值运算符(以及自定义复制构造函数)

string & operator = (const string &s) {
  if (str == s.str)  return *this;   // 避免 s = s 所产生的错误
  if (str)  delete []str;
  str = new char[strlen(s.str) + 1];
  strcpy(s, s.str);
  return *this;                      // 返回自己
}

operator = 返回类型的讨论:考虑 a = b = c(a = b) = c,采用 string& 引用作为返回值最好

  • 运算符重载为友元函数
    通常我们将运算符重载为成员函数。
    在特殊情况下我们需要将运算符重载为普通函数。但普通函数不能访问类的私有成员,此时我们就将运算符重载为友元函数。

  • 流插入运算符与流提取运算符的重载
    首先我们要知道 cincout 分别是类 istreamostream 的对象
    当我们重载 >><< 运算符时,由于 istreamostream 是库中完整的定义,所以我们只能将其重载为普通函数
    为了实现 (((cin >> a) >> b) >> c) 流的连续流入,重载的返回值应该仍是 cincout
    下面是重载 << 运算符实现对自定义类 complex 输出的例子

class complex {
  int real, imag;
 public:
  complex() {}
  complex(int r, int i) : real(r), imag(i) {}
  complex(const complex &c) : real(c.real), imag(c.imag) {}
  friend ostream & operator << (ostream &os, const complex &c);
};
ostream & operator << (ostream &os, const complex &c) {
  os << c.real << '+' << c.imag << 'i';   // 以 a+bi 形式输出
  return os;
}
  • 自加自减运算符的重载
    ++--运算符是有前置 / 后置之分的
    其中,前置运算符作为一元函数重载 (仅有一个参数,类型为该类) -> 重置为成员函数无参
    后置运算符作为二元函数重载 (有两个参数,类型分别为该类和 int) -> 重置为成员函数一参
class Cdemo {
  int n;
  Cdemo(int i) : n(i) {}
  Cdemo operator ++ ();    // 前置且成员
  Cdemo operator ++ (int k); // 后置且成员,该参数无意义
  friend Cdemo operator -- (Cdemo &c);           // 前置且全局
  friend Cdemo operator -- (Cdemo &c, int k);    // 后置且全局
  operator int() { return n; }  // int 类型转换运算符重载: 不能写返回值类型,返回值类型其实就是转换运算符的类型
};
Cdemo Cdemo::operator ++ () {
  ++n;
  return *this; 
}
Cdemo Cdemo::operator ++ (int k) {
  Cdemo tmp(*this);
  ++n;
  return tmp;
}
Cdemo operator -- (Cdemo &c) {
  c.n--;
  return c;
}
Cdemo operator -- (Cdemo &c, int k) {
  Cdemo tmp(c);
  --c.n;
  return tmp;
}

继承与派生

在定义一个新的类 \(B\) 时,如果该类与某个已有的类 \(A\) 相似(指的是 \(B\) 拥有 \(A\) 的全部特点)
那么就可以把 \(A\) 作为一个基类,而把 \(B\) 作为基类的一个派生类
派生类拥有基类的全部成员函数与成员变量,包括public,protected, private
但注意,派生类的成员函数是不能访问基类的 private成员的 -> 只可以通过友元或调用基类的 public 函数实现间接的访问

class CStudent {
  private:
    string sName;
    int nAge;
  public:
    bool IsThreeGood();
    void SetName(const string &name) { sName = name; }
    //...
};
class CUnderguaduatedStudent : public CStudent {   // CUndergraduatedStudent 是 CStudent 的一个公有派生类
  private:
    int nFaculty;
   public:
    bool IsThreeGood() {   // 派生类与基类拥有同名同参数表函数:这种现象称为覆盖,体现类派生类对基类的修改
      if (CStudent::IsThreeGood)    // 在派生类中调用被覆盖基类成员的格式: 基类名::基类成员名
        return true;
      return false;
    }
    bool CanBaoYan();
};

基类的 private 成员:能被基类的成员函数与友元函数访问
基类的 public 成员:能被任意函数访问
基类的 protected 成员:能被基类的成员函数,友元函数与派生类的成员函数访问,但注意,一定是当前对象的基类的 protected 成员!

class Father {
  private: int nPrivate;
  public:  int nPublic;
  protected:  int nProtected;
};
class Son : public Father {
  void accessFather {
    nPublic = 1;       // OK!
    nPrivate = 1;      // Wrong!不能直接访问基类的 私有成员
    nProtected = 1;    // OK!可以访问从基类继承的 受保护成员
    Father antherFather;
    anotherFather.nProtected = 1;  // Wrong!访问的不是当前对象的基类的 受保护成员
  }
}

派生类的构造函数

在创建派生类的对象时,总是先初始化派生类对象中从基类继承的成员
在执行一个派生类的构造函数之前,总是先执行其基类的构造函数
调用基类构造函数分两种方式:
显式方式:
derived::derived(arg_derived-list) : base(arg_base-list) {} 为基类构造函数提供相应参数
arg_derived-list 包含 arg_base-list
隐式方式:
省略基类构造函数时,自动调用其默认构造函数

构造函数总调用顺序:基类构造函数 -> 成员对象类构造函数 -> 派生类构造函数
析构函数总调用顺序:派生类析构函数 -> 成员对象类析构函数 -> 基类析构函数


public 继承的赋值兼容规则

派生类的对象可以赋值给基类对象;反之不允许
派生类对象可以初始化基类引用;反之不允许
派生类地址可以赋值给基类指针;反之不允许
因为:派生类对象可以视作包含基类对象,且放在派生类对象的最前面


直接基类与间接基类

\(A\) 派生-> \(B\) 派生-> \(C\) 派生-> \(D\)
\(C\)\(D\) 的直接基类:\(A\), \(B\), \(C\)\(D\) 的间接基类
在声明派生类时,只需要列出其直接基类:派生类沿着类的层次自动向上继承间接基类的所有成员
在多层派生中,由上至下执行构造函数;由下至上执行析构函数


复合关系与继承关系

  • 继承关系:"是"关系
    基类 \(A\) -> 派生类 \(B\)
    逻辑上要求 \(一个B对象也是一个A对象\),例子:由学生类派生出中学生类,人类派生出男人类与女人类
  • 复合关系:"有"关系
    \(C\) 中的成员变量 \(k\) 是类 \(D\)
    逻辑上要求:\(D\) 对象是 \(C\) 的固有属性或组成部分
    若出现相互复合的情况:如人类与狗类,人类中需要记录自己拥有的狗;狗类中需要记录自己的主人
    最好采用定义对象指针的方法:人类中有一个狗类指针指向自己拥有的狗;狗类中有一个人类指针记录自己的主人

多态与虚函数

之前在学习 \(SML\) 时,提到了多态 \((polymorphism)\) :的概念,我当时的感性理解是:一个函数可以作用于多个不同种类的对象,如:

a = 1;
b = "hello";
c = 3.14;
SOME a // SOME 函数作用于 int 类型 a -> 构造出一个 SOME int 对象
SOME b // SOME 函数作用于 string 类型 -> 构造出一个 SOME string 对象
SOME c // SOME 函数作用于 real 类型 -> 构造出一个 SOME real 对象

\(SML\) 中,存在着 \(type inference\) 的特性,因此函数的参数可以不声明类型,因此多态的实现很好理解;
\(C++\) 中,函数的参数是必须声明类型的,所以多态的实现令我感到十分好奇:学完这一课后,我知道了多态的实现依靠的是 继承机制与虚函数


  • 多态与虚函数的基本概念
  1. 虚函数:在类的定义中,前面有 virtual 关键字的成员函数就是虚函数;构造函数与静态成员函数不能是虚函数
  2. 多态:以下是多态的一种表现形式
    派生类的指针可以赋给基类指针
class Base {
  ...
  virtual func(arg-list) {} 
};
class Derived : public Base {
  ...
  virtual func(same_arg-list) {} 
};
Derived obj;
Base* p = *obj;  // 派生类指针可以赋给基类指针
p->func();       // 调用的是 Derived 类的 func 函数而不是 Base 类的 func 函数
Base& r = obj;   // 派生类对象可以赋给基类引用
r.func();        // 同上,调用的是 Derived 类的 func 函数而不是 Base 类的 func 函数

通过基类指针调用基类和派生类中的同名同参数表的虚函数时,若该指针指向基类对象,那么被调用的是基类的虚函数;若该指针指向的是派生类对象,那么被调用的是派生类的虚函数
调用哪个虚函数取决于基类指针指向那种类型的对象:这种机制就被称为多态
第二种表现形式:将以上的 指针 改为 引用 即可
在面向对象的程序设计中使用多态,能够增强程序的可扩充性

  • 使用多态的游戏程序实例
    游戏中有生物 食尸鬼 \((Ghoul)\),妖灵 \((wraith)\),人类 \((Human)\),任何一种生物都可以进行主动攻击(attack 函数),被攻击(hurt 函数),反击(fightback 函数),且攻击动作,被攻击动作各不相同(每类的 attack 函数与 hurt 函数实现方式不同)
    现向游戏中添加一种新的生物 精灵 \((elf)\)
    不使用多态:
class Creature { ... };
class Ghoul : public Creature {
  ...
  void attack_wraith(Wraith *w) {
    ... // 食尸鬼攻击特征
    w->hurt();  // 调用对应妖灵的被攻击函数
    w->fightback(*this);  // 被攻击的妖灵向自身发起反击
    ... 
  }
  void attack_human(Human *h) {
    ... // 食尸鬼攻击特征
    h->hurt();  // 调用对应人类的被攻击函数
    h->fightback(*this);  // 被攻击的人类向自身发起反击
    ...
  }
  ....... // 对每一个相应的生物,都要写一个对应的 attack 函数
  .......
};

可以发现,这样的方法实在是太麻烦了:每个 \(attack\) 函数有着高度的相似性,只是攻击的对象类型不同
并且维护起来也十分麻烦,对于新增的 精灵 生物,我们对于每一个原有的生物类都必须添加一个 attack_elf 函数...
但由于 \(C++\) 中的参数类型必须被声明,我们只能一个一个写,如果在 \(SML\) 中,只要一个函数就能解决了...
然而,借助 \(C++\) 中的多态与虚函数,我们也可以做到一个函数解决!
使用多态:

class Creature {
  ...
  virtual void hurt();
  virtual void fightback(Creature* c); // 基类中定义两个虚函数
};
class Ghoul {
  ...
  void attack(Creature* c) {          // 其他所有生物都是 creature 类的派生类,因此其指针均可以赋给该函数参数中的 Base 类指针
    ... // 有食尸鬼特色的攻击方式
    c->hurt();                        // 基类指针调用的虚函数即是其指向的对象的基函数
    c->fightback(this);
    ..
  }
  ...
};

这样每个生物只需要实现一个 attack 函数就行了!在添加新生物时也不用对已存在生物类进行修改,只需要在新生物类内声明好虚函数 hurt, fightback 即可,维护起来也十分方便

  • 使用多态的其他程序实例
    略,总结两点
  1. 用基类指针数组存放指向各种派生类对象的指针,然后遍历该数组,就能对各个派生类对象做各种操作,是很常用的做法
  2. 构造函数与析构函数中调用虚函数,不是多态
    这一点理解起来有点麻烦,见代码
class Base {
  virtual void foo() { cout << "Base!" << endl; }
  Base() {
    foo();
    this->foo();
  }
};
class Derived : public Base {
  virtual void foo() { cout << "Derived!" << endl; }  // 同名虚函数
  Derived() {
    foo();
  }
};
Derived obj;

以上这个程序,按照之前学过的多态定义来解释,我们期望的输出应该是这样的:

Base!
Derived!   // 多态表现
Derived!

因为在构造派生类对象时,先执行基类构造函数再执行派生类构造函数:在执行基类构造函数时,我们遇到了两条语句,第一条是
foo();
这一条语句采用静态绑定,调用的是 \(Base\) 类定义中的 \(foo()\) 函数,输出 Base!,下一句
this->foo();
我们知道,this 指针指向的是当前对象 obj,而 obj 是派生类 Derived 类的,满足多态定义 通过基类指针调用派生类的同名虚函数,应当输出的是派生类中的虚函数 foo(),因此应打印 Derived!
最后执行派生类对象的构造函数,采用静态绑定,输出 Derived!
然而,真正程序执行时输出的却是

Base!
Base!
Derived!

也就是说,基函数中的构造函数第二句 this->foo() 调用的是基函数中的 foo() ,采用的是静态绑定而不是多态。
这也就是编译器中规定的 在构造函数和析构函数中调用虚函数不采用多态
这样做的原因是,由于派生类对象构造函数初始化在基类构造函数之后执行,如果在基类构造函数中调用派生类对象中的虚函数,可能会使用到还未初始化的派生类成员变量,这很容易引发错误。


多态实现原理

多态的关键在于,当基类指针或引用调用一个虚函数时,编译时是无法确定其调用的虚函数到底是基类的还是派生类的,只有在运行时才确定,这被称为"动态联编"
而动态联编是借助虚表完成的

引用自知乎 可乐船长2020

  • 在实例化对象时,编译器检测到虚函数时,会将虚函数的地址放到虚表(类似于一个存放函数指针的数组)中
  • 当实例化子类时,检测到有虚函数的*重写,编译器会用子类重写的虚函数地址覆盖掉之前父类的虚函数地址,当调用虚函数时,检测到函数是虚函数就会从虚表中找对应的位置调用,若子类没有重写,虚表中的虚函数地址就还是父类的,若子类中有重写,虚表记录的就是子类重写的虚函数地址,即实现了父类的指针调用子类的函数
    注:重写即在派生类中定义与基类中同名同参数表的虚函数以实现覆盖

虚析构函数

之前提过,函数的构造函数不能是虚函数,而其析构函数可以是虚函数,且在有些情况下必须是虚函数。这里就要引用虚析构函数的概念:
问题引入:

class Base {
public:
  ~Base() {}    // 更为 virtual ~Base() {} 即可
};
class Derived : public Base {
  public:
    ~Derived() {}
};
Base* p = new Derived;
delete p;

在以上的程序中,我们 delete p 时,只会执行 Base 类中的析构函数,这样会产生许多问题
比如 Derived 类中新申请的空间可能无法完全释放,或者一些计数方面的变量会受到影响
通过基类指针删除派生类对象时,正确的 delete 方法应是先调用派生类的析构函数,再调用基类的析构函数
而要做到这样,我们只需要定义虚析构函数,也就是,把基类的析构函数声明为 virtual 即可,而派生类的析构函数可以不进行声明
一般来说,类如果定义了虚函数,那么最好将其析构函数也定义为虚函数


纯虚函数与抽象类

  • 纯虚函数:没有函数体的虚函数
class A {
  int a;
public:
  virtual void func() = 0;  // 纯虚函数,格式为将函数体替换为 =0 标记
}
  • 抽象类:包含纯虚函数的类
    抽象类只能作为 基类 来派生新类使用
    不能创建抽象类的对象
    可以创建抽象类的指针与引用,但一定只能被其派生类对象赋值
A a;  // Wrong! A 是抽象类,不能创建对象
A *a; // 可
a = new A;  // Wrong! 

抽象类中,在成员函数内可以调用纯虚函数,构造/析构函数中则不能调用
如果一个类从抽象类中派生而来,那么它必须实现基类中所有的纯虚函数才能成为非抽象类

class A {
  public:
    virtual void f() = 0;
    void g() { this->f(); }    // 在成员函数中调用纯虚函数是允许的
    A() {}
};
class B : public A {
  public:
    void f() { cout << "Yeah\n"; }
    B() {}
} b;
b.g();   // 输出 Yeah

可以认为抽象类是实现多态的一个载体,其本身并不能实例化:
引用自百度百科:

在实现接口时,常写一个抽象类,来实现接口中的某些子类所需的通用方法,接着在编写各个子类时,即可继承该抽象类来使用,省去在每个都要实现通用的方法的困扰。


文件操作

  • 文件与流
  • 文件打开 -> 文件读写 -> 文件关闭
  • 文件的读写指针
  • 二进制文件读写
    这里老师讲的也不深,我之前用的也不多,所以听得有些懵懵懂懂的,,,等之后具体需要用的时候再深入研究吧
  • coutcerr

函数模板与类模板

先介绍一下泛型程序设计(Generic Programming)的概念:算法实现时不指定具体要操作的数据类型 (感觉和多态有点像?)
于是可以基于此大量编写模板,使用模板的程序设计

  1. 函数模板: 众多的函数抽象为函数模板
template <class T>
void Swap(T& a, T& b) {
  T tmp = a;
  a = b;
  b = tmp;
}

如上我们创建了一个可以交换任意相同类型变量的 Swap 函数
函数模板工作的原理:编译器根据实参类型创建一个由模板实例化的普通函数
重载的同名函数调用顺序:
Step.1 参数完全匹配的普通函数
Step.2 参数完全匹配的模板实例化函数
Step.3 实参经过强制类型转换可以匹配的普通函数
同样要注意避免二义性:尽量定义不同类型的形参

template <class T>
void func(T a, T b) { ... }
func(1, 2.0);      // 存在二义性, T 到底实例化为 int 型还是 double 型?

// 正确的定义方法:
template <class T1, class T2>
void func(T1 a, T2 b) { ... }
func(1, 2.0);       // ok. T1 实例化为 int, T2 实例化为 double
  1. 类模板: 众多相似的类抽象为类模板
    定义一批相似的类 -> 定义类模板 -> 生成不同的类(模板实例化)
    以定义一个 \(pair\) 类为例
template <class T1, class T2>
class Pair {
  public:
    T1 key;
    T2 value;
    Pair(T1 k, T2 v) : key(k), value(v) {}
    bool operator < (const Pair& p) const;
};
template <class T1, class T2>
bool Pair<T1, T2>::operator < (const Pair& p) const { return value < p.value; }

Pair<string, int> student("Tom", 19);    // 实例化出一个类 Pair<string, int> 并且用该实例化的类定义一个 Student

如上,在类模板外定义的成员函数也要添加 template<类型变量名> 以及 类名<类型变量名>
(重要)编译器由类模板生成类的过程叫做类模板的实例化,由类模板实例化得到的类叫做模板类
类模板的的类型参数表中可以出现非类型参数,如

template<class T, int SIZE>
class CArray {
  T array[SIZE];
.....
};
CArray<int, 20> obj;    // 个数 => 类的特性,也可以通过非类型参数标记

类模板与继承:
有四种情况,类模板派生出类模板

template<class T1, class T2>
class A {
  T1 v1;
  T2 v2;
};
template <class T3>
class B : public A<T1, T2> {
  T3 v3;
};

模板类(请注意:模板类是一个类!)派生出类模板

template <class T1, class T2>
class A { T1 v1; T2 v2; };
template <class T3>
class B : public A<string, int> {   // A<string, int> 是 A<T1, T2> 类模板实例化出来的模板类
  T3 v3;
};

普通类派生出类模板

class A { int a; }
template<class T1, class T2>
class B : public A {
  T1 v1;
  T2 v2;
};

模板类派生出普通类

template <class T1, class T2>
class A {
  T1 v1; T2 v2;
};
class B : public A<string, int> {
  int x;
};

类模板只能派生出类模板,实例化出模板类


String 类

  • 包含头文件 <string>
  • string 对象的初始化
string s = "hello";
string s("hello");
string s(8, 'x');  // xxxxxxxx
  • length()size()
  • '+' 连接
  • '<' '>' '==' '!=' 比较运算符
  • str.substr(1, 5)
    字符串 str 从下标 \(1\) 开始的 \(5\) 个字符
  • str.find("hi")str.rfind("hi")
    从前往后 / 从后往前搜索 hi 第一次出现位置的下标
    找不到则返回 string::npos
  • str.find_first_of("abcd")str.find_last_of("abcd")
    从前向后 / 从后往前 找到 abcd 字符集中第一个出现字符的位置
  • str.find_first_not_of("abcd")str.find_last_not_of("abcd")
    从前往后 / 从后往前找到 不是 abcd 字符集中任意字符的字符第一次出现的位置
  • str.erase()
  • str.insert()
  • str.replace()
  • str.c_str()
    string 类型字符串转化为 C 语言式 传统的 const char* 类型字符串
string a;
cin >> a;
printf("%s\n", a.c_str());

STL

STL之前接触的很多,万能又好用!
这次概念性的学习一下!

  • \(STL\) 中的基本概念
    容器:属于类模板,容纳数据类型的通用数据结构,任何插入对象中的元素都是一个拷贝
    迭代器:类似于指针,用于访问容器中的元素
    算法:用于操作容器内元素的函数模板

  • 容器:
    常用的有很多,基本都接触过:
  1. 顺序容器 \(vector\), \(deque\), \(list\)
    \(vector\) : push_back(), pop_back(), insert(),erase(), front(), back()
    推荐在尾端添加元素,可以在任意位置添加元素,支持随机访问,随机访问迭代器
    \(deque\) : push_front(), pop_front()
    推荐在尾部或头部添加元素,可以在任意位置添加元素,支持随机访问,随机访问迭代器
    \(list\) : sort() (内置,不可用 algorithmsort), merge(), unique()
    在任意位置添加元素都较快,但是不支持随机访问,双向迭代器
  2. 关联容器(内部有序) \(set\), \(multiset\), \(map\), \(multimap\)
    insert()
    支持双向迭代器
  3. 容器适配器 \(queue\), \(priority_queue\), \(stack\)
    队列先进先出:只能在尾部添加元素,在头部访问或弹出元素,不支持迭代器
  • 迭代器
    定义方法:
    容器类名 :: iterator 迭代器名
    常量迭代器 : (只访问不修改)
    容器类名 :: const_iterator 迭代器名
    反向迭代器 : (从rbegin()自增至rend()进行遍历)
    容器类名 :: reverse_iterator 迭代器名
    迭代器又分两大类:双向迭代器随机访问迭代器
    双向迭代器只能支持最基础的自加自减操作
    而随机访问迭代器则可以像指针一样,支持如 i+5i-5 随机移动的迭代器;并且使用 < 等比较运算符对迭代器进行比较
    我们可以发现支持随即访问迭代器的容器一般都可以支持通过 []at() 快速访问某下标的元素
  1. \(bitset<size>\) 神器,之前就用过很多

STL 算法

  1. 不变序列算法 -> 适用于顺序容器,关联容器
    min_element(), max_element() : 返回的是迭代器!
    count() : 计算区间内等于某值的元素个数
    count_if() : 计算区间内满足某种条件(op()=true)的元素个数
    find() : 查找区间内等于(==)某值的元素,返回所在迭代器
  2. 变值算法 -> 顺序容器
    for_each() : 对区间内每个元素都做某种操作
    copy() : 复制一个区间到别处
    补充:一个 copy() 的奇技淫巧
ostream_iterator<int> o(cout, " ");
int a[5] = {0, 1, 2, 3, 4};
vector<int> vec(a, a + 5);
copy(vec.begin(), vec.end(), o);   // 输出 0 1 2 3 4

fill() : 用某个值去填充区间
replace() : 将区间内的所有某个值替换成另一个值
replace_if() : 将区间内所有满足某种条件(op(x)=true)的值替换成另一个值
3. 删除算法 -> 删除一些容器内的某些元素
remove() : 删除区间内所有等于某个值的元素
注意:remove 函数的本质是元素位置的移动,并不会真正减少元素个数,但它会返回删除操作后的尾迭代器

a[5] = {0, 2, 3, 2, 5};
int* p = remove(a, a + 5, 2);
cout << p - a << endl; // 输出 3:有效元素还剩 3 个

remove_if() : 删除满足某个条件的元素
unique() : 删除区间内连续相等的元素,返回最后一个有效元素之后的迭代器:配合 sort() 可快速去重且得到去重后区间
4. 变序算法
random_shuffle() : 老熟人了。打乱一个区间内的元素顺序
reverse() : 反转区间
next_permutation(), prev_permutation() : 将区间改为后一个 / 前一个排列,可快速生成全排列
5. 排序算法
sort() : 不稳定排序,平均性能最优,本质快排
stable_sort() : 稳定排序,排序且保持相等元素间的相对次序,最坏时间复杂度优于 sort,本质归并排序
均可自定义比较器
6. 有序区间算法:需要区间有序
binary_search() : 判断区间是否包含某个元素 (x<yx>y均不成立即x==y)
lower_bound() : 最后一个不小于某个值的位置,返回迭代器 / 指针
upper_bound() : 第一个大于某个值的位置,返回迭代器 /指针
equal_range() : 同时获取 \(lower_bound\)\(upper_bound\),返回 \(pair\),其中 \(first\)\(lower_bound\)\(second\)\(upper_bound\)
7. \(bitset\) 算法
set() 全部置为 \(1\)
set(int pos) \(pos\) 位置为 \(1\)
reset() 全部置为 \(0\)
reset(int pos) \(pos\) 位置为 \(0\)
flip() 所有位翻转
flip(int pos) \(pos\) 位翻转
count() \(1\) 的个数

函数对象

当一个类将 () 运算符重载为成员函数之后,这个类的对象就是函数对象
函数对象本质上是一个对象,但其使用的形式类似一个函数调用,实际上也进行了函数调用,因此得名

class CAverage {
  double operator()(double x, double y, double z) {
    return (x + y + z) / 3;
  }
};
Caverage average;  
cout << Caverage(1.0, 2.0, 3.0) << endl;

以上形式很像一个函数调用吧?
实际上 average(1.0, 2.0, 3.0) 就是 average.operator(1.0, 2.0, 3.0)
这使得 average 看似是函数名,实则是一个对象
C++ 内置了许多函数对象,我们可以在合适的时候进行调用
例如 algorithm 库中的 sort 函数,提供了一个可以让我们传入自己定义的比较函数的参数 cmp
sort 比较两个数的 "大小" 并决定将谁排在前面时,将会调用 cmp 函数进行比较
我们可以自己实现 cmp 函数,还可以使用 C++ 自带的函数对象 greater. greater 在 C++ 中的定义如下:

template <class T>
struct greater
{
    bool operator()(const T& x, const T& y) const{
        return x > y;
    }
};

因此我们只需要传入 greater<int>() 这个函数对象作为 cmp 参数即可(该函数对象是一个临时的匿名对象)
当用其他自定义的类实例化 greater 时,确保类中重载了 >
此外,函数对象还可用于 accumulate 函数中的累加函数参数
这里 说的很详细
函数对象的功能比一般对象更为强大,因为我们可以通过构造函数创建不同的临时函数对象, 从而实现不同的功能, 而不必对每种情况都定义不同的函数

posted @ 2022-04-18 12:49  四季夏目天下第一  阅读(72)  评论(0编辑  收藏  举报