再学 C++ 程序设计

· · 算法·理论

这是一个大学程序设计课的复习笔记,大部分的内容在 oi 里都用不到。

这里默认大家学习过 C 语言程序设计。

1 from C to C++

1.1 C++ 的源代码后缀是 .cpp,编译器是 GNU C++,或简写为 g++。

1.2 流输入输出

包含在 iostream 头文件中,输入的语法是 cin >> a; 输出为 cout << a;。C++ 作为经典的面向对象语言,学习之前需要知道什么是“对象”。

对象是现实世界或抽象世界中事物的一种计算机表示。可以理解为一个“变量”就是一个对象,一个函数也是对象等。对象是类的实例。类是对象的模板,定义了对象的属性和行为。

cout,cin,cerr 则是 iostream 头文件中的输入输出流对象。这里“流”大概是取自“水流”这个词。

<<>> 分别被重载为输出和输入运算符。如果成功,会返回一个指向左值的引用,这允许链式调用。如果提取失败,比如因为输入的数据类型与变量的类型不匹配,状态会变为失败(fail),并且会返回一个错误的状态。

(挖坑)https://zhuanlan.zhihu.com/p/24926755931

控制小数输出:

#include <iostream>
#include <iomanip>
using namespace std;
int main() {
  double n = 123.4567;
  cout << fixed << setprecision(10) << n;
}

1.3 命名空间

C++ 的命名空间机制可以用来解决复杂项目中名字冲突的问题。

举个例子:C++ 标准库的所有内容均定义在 std 命名空间中,如果你定义了一个叫 cin 的变量,则可以通过 cin 来访问你定义的 cin 变量,通过 std::cin 访问标准库的 cin 对象,而不用担心产生冲突。

命名空间用下述代码声明和使用:

namespace xxx{
  ...
  void f() {
    std :: cout << "Hello World\n";
  }
}
int main() {
  xxx :: f();
}

命名空间也是可以嵌套的。还可以使用 using :: 指令:

using xxx::f 这条指令可以让我们省略某个成员名前的命名空间,直接通过成员名访问成员,相当于将这个成员导入了当前的作用域。

using namespace xxx 这条指令可以直接通过成员名访问命名空间中的任何成员,相当于将这个命名空间的所有成员导入了当前的作用域。

1.4 预处理命令

预处理命令就是预处理器所接受的命令,用于对代码进行初步的文本变换,比如 文件包含操作 #include 和处理宏 #define 等。

(挖坑)

1.5 引用

引用可以看成是 C++ 封装的非空指针,可以用来传递它所指向的对象,在声明时必须指向对象。

引用不是对象,因此不存在引用的数组、无法获取引用的指针,也不存在引用的引用。

引用可以看成对象的别名,在声明是不会额外生成新的对象。

通常我们会接触到的引用为左值引用,即绑定到左值的引用,同时 const 限定的左值引用可以绑定右值。

#include <iostream>
#include <string>

int main() {
  std::string s = "Ex";
  std::string& r1 = s;
  const std::string& r2 = s;

  r1 += "ample";  // 修改 r1,即修改了 s
  // r2 += "!"; // 错误:不能通过到 const 的引用修改
  std::cout << r2 << '\n';  // 打印 r2,访问了s,输出 "Example"
}

左值引用最常用的地方是函数参数,用于避免不需要的拷贝。函数的返回值也可也作为引用:

#include <iostream>
#include <string>

// 参数中的 s 是引用,在调用函数时不会发生拷贝
char& char_number(std::string& s, std::size_t n) {
  s += s;  // 's' 与 main() 的 'str'
           // 是同一对象,此处还说明左值也是可以放在等号右侧的
  return s.at(n);  // string::at() 返回 char 的引用
}

int main() {
  std::string str = "Test";
  char_number(str, 1) = 'a';  // 函数返回是左值,可被赋值
  std::cout << str << '\n';   // 此处输出 "TastTest"
}

https://oi-wiki.org/lang/reference/

1.6 string 类

C++ 提供了以下两种类型的字符串表示形式:C 风格字符串、C++ 的 string 类。

/* string 类的几种创建方式 */
#include <iostream>
#include <string>
using namespace std;
int main() {
 string s1;
 string s2 = "c++";
 string s3 = s2;
 string s4(5, 's');
}

和许多 STL 容器相同,string 能动态分配空间,这使得我们可以直接使用 std::cin 来输入,但其速度则同样较慢。这一点也同样让我们不必为内存而烦恼。

string 的加法运算符可以直接拼接两个字符串或一个字符串和一个字符。string 重载了比较运算符,同样是按字典序比较的,所以我们可以直接调用 std::sort 对若干字符串进行排序。

1.7 const,constexpr

const 修饰的变量被定义为了只读,不可以修改,const 修饰的指针和引用也是如此。关于指针还需注意:

int* const p1;  // 指针常量,初始化后指向地址不可改,可更改指向的值
const int* p2;  // 常量指针,解引用的值不可改,可指向其他 int 变量
const int* const p3;  // 常量指针常量,值不可改,指向地址不可改

关于 const 成员函数:

const 限定的成员函数,可以用来限制对成员的修改。任何成员函数的参数都是含有 this 指针的,这个参数就是对象本身。如果一个对象被定义成了 const,则其 this 指针的类型也是 const,只能被 const 成员函数修改。

const 成员函数不能调用非 const 成员函数,const 成员函数不能修改成员变量,常量不能调用非 const 成员函数。

C++11 标准新添加的关键字 constexpr,声明编译时可以对函数或变量求值。即限定为常量表达式或限定为编译时可优化执行的函数。

1.8 auto 关键字

auto 关键字在编译时自动替换为对应的数据类型。

auto 的作用就是为了简化变量初始化,如果这个变量有初始化表达式,就可以用auto代替类型声明。也就是说 auto 声明的变量必须初始化。

1.9 new 和 delete

C 语言中用 malloc 和 free 申请动态空间,当在 C++ 中处理对象时很容易内存泄漏。C++ 提供了封装好的 new 和 delete 关键字。

//申请空间
int* ptr = new int;
//申请空间并初始化
int* ptr2 = new int(1);
//申请连续的空间,空间大小为4*10=40
int* arr = new int[10];//(C++11)

//释放单个空间
delete ptr;
delete ptr2;

//释放连续的多个空间
delete[] arr;

malloc 和 free 不会对我们自定义类型完成初始化和资源的清理,而 new 可以完成对象的初始化和 delete 可以完成对象的资源清理。这在学习了第 2 部分的内容会更好理解。

动态创建的堆空间对象不会自动析构,所以一定需要记得 delete。

2 类与面向对象编程

类(class)是结构体的拓展,不仅能够拥有成员元素,还拥有成员函数。

在面向对象编程(OOP)中,对象就是类的实例,也就是变量。

2.1 类的声明

类似 C 语言的 struct,C++ 中利用 class 关键字声明类。C++ 中同时保留了 struct 关键字,等同于 class 的 public。

/* 一个实例 */
class Object {
 public:
  int weight;
  int value;
} e[array_length];

const Object a;
Object b, B[array_length];
Object *c;

关于访问说明符

public:该访问说明符之后的各个成员都可以被公开访问,简单来说就是无论类内还是类外都可以访问。

protected:该访问说明符之后的各个成员可以被类内、派生类或者友元的成员访问,但类外不能访问。

private:该访问说明符之后的各个成员只能被类内成员或者友元的成员访问,不能 被从类外或者派生类中访问。

一般情况下,数据成员需要是私有的,想要访问必须通过类内的函数进行访问。调用者不需要知道类的数据成员,而可以直接通过调用成员函数。同时也不需要知道成员函数的内部具体实现。

2.2 成员函数

成员函数可以定义在类定义内部,或者单独使用作用域解析运算符 :: 来定义。

举例:

// 这两份代码是等价的
class object{
private:
  int a;
public:
  void set(int _a) {
    a = _a;
  }
};

class object{
private:
  int a;
public:
  void set(int _a);
};
void object::set(int _a) {
  a = _a;
}

成员函数的外部实现

在 DATE.cpp 中预处理引入 DATE.h,同时需要用到 :: 运算符来指明该函数是类 DATE 的成员函数。DATE.cpp中有时还包括DATE内部要使用到的函数,例如DaysInMonth。这种函数并非对外公开供用户使用,因此可以将其声明为类的私有成员。若在该函数中没有涉及该类的数据成员,则无需将它们声明为类的成员。

2.3 构造函数

类的构造函数是类的一种特殊的成员函数,每次创建类的新对象时执行它完成初始化等逻辑。

构造函数的名称与类的名称是完全相同的,并且不会返回任何类型,也不会返回 void

注意:如果用户没有自定义构造函数,则编译会自动生成一个默认构造函数。

// Example:
class Object {
 public:
  int weight;
  int value;

  Object() {
    weight = 0;
    value = 0;
  }
};
/*
该例定义了 Object 的默认构造函数,
该函数能够在我们实例化 Object 类型变量时,
将所有的成员元素初始化为 0。
(C++11)一种等价的写法是:
Object() : weight(0), value(0) {}
关于这种写法的原理,以后会提及。一个注意的点是这个花括号是函数体。
*/

构造函数也可以带有参数。这样在创建对象时就可使用参数构造对象。注意:用户一旦定义了构造函数,编译器就不再自动添加默认构造函数,这时调用无参构造会出错。

如果仍然需要默认构造函数,用户可以再自行定义,或者使用 default 关键字。

在 C++ 中约定如果一个类中自定义了带参数的构造函数,那么编译器就不会再自动生成默认构造函数,也就是说该类将不能默认创建对象,只能携带参数进行创建一个对象;

但有时候需要创建一个默认的对象但是类中编译器又没有自动生成一个默认构造函数,那么为了让编译器生成这个默认构造函数就需要 default 这个属性。

#include <iostream>
using namespace std;
class A {
  public:
  A(int x) {
    cout << "This is a parameterized constructor";
  }
  A() = default;
};

=default 仅要用于编译器能自动生成的特殊成员函数,包括默认构造函数,析构函数,赋值运算符,复制构造函数。

2.4 析构函数

每一个变量都将在作用范围结束走向销毁。

但对于已经指向了动态申请的内存的指针来说,该指针在销毁时不会自动释放所指向的内存,需要手动释放动态内存。

如果结构体的成员元素包含指针,同样会遇到这种问题。需要用到析构函数来手动释放动态内存。

析构 函数(Destructor)将会在该变量被销毁时被调用。重载的方法形同构造函数,但需要在前加 ~

2.5 再谈 this 指针

在 C++ 中,每一个对象都能通过 this 指针来访问自己的地址(this 指针是指向当前对象的指针)。this 指针是所有成员函数的隐含参数。因此,在成员函数内部,它可以用来指向调用对象。

当成员函数参数与成员数据重名时,必须用 this 访问成员数据。

this 指针还可以用来实现链式调用。它通过在每个成员函数中返回 *this,使得多个成员函数调用可以在同一行内连续进行。

2.6 类的静态成员

静态(static)成员是类的组成部分但不是任何对象的组成部分。静态数据成员具有静态(全局)生存期,是类的所有对象共享的存储空间,是整个类的所有对象的属性,而不是某个对象的属性。

与非静态数据成员不同,静态数据成员不是通过构造函数进行初始化,而是必须在类定义体的外部再定义一次,且恰好一次,通常是在类的实现文件中再声明一次,而且此时不能再用 static 修饰。静态成员函数不能直接访问类的非静态成员,只能直接访问类的静态数据或函数成员。

2.7 弃置函数(=delete)

简单来说就是把某个函数删除,删除的函数定义必须是函数的首次声明。

2.8 成员初始化器列表

构造函数名字、参数列表和函数体之间,可选初始化器列表。初始化器列表以冒号开头,后跟一系列以逗号分隔的成员初始化器,如成员初始化构造函数、该类的其他构造函数。

必须使用【成员初始化列表】的情况

  1. const 类型成员
  2. 初始化成员是对象
  3. 继承类初始化基类的 private 成员

注意:数据成员被初始化的顺序与他们出现在类声明中的顺序相同,与初始化器中的排列顺序无关。

2.9 拷贝构造函数

用本类型对象引用作第一形式参数的构造函数。该参数传递方式为对象引用,避免在函数调用过程中生成形参副本。该形参声明为const,以确保在拷贝构造函数中不修改实参的值。

若用户未提供拷贝构造函数,则该类使用由系统提供的缺省拷贝构造函数。

(挖坑)

3 运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有返回值类型,函数名和参数列表。

重载的运算符可以理解为带有特殊名称的函数,函数名是由关键字 operator 和其后要重载的运算符符号构成的。

3.1 重载运算符的方式

一般有两种方式:

其一,类内重载(即重载为成员函数):

当重载为成员函数时,因为隐含一个指向当前成员的 this 指针作为参数,此时函数的参数个数与运算操作数相比少一个。

class Example {
  // 成员函数的例子
  return_type operator operator_name (除本身外的参数) { /* ... */ }
};

其二,类外重载:

return_type operator operator_name(所有参与运算的参数) { /* ... */ }

自增自减运算符 自增自减运算符分为两类,前置(++a)和后置(a++)。为了区分前后置运算符,重载后置运算时需要添加一个类型为 int 的空置形参。

struct MyInt {
  int x;

  // 前置,对应 ++a
  MyInt &operator++() {
    x++;
    return *this;
  }

  // 后置,对应 a++
  MyInt operator++(int) {
    MyInt tmp;
    tmp.x = x;
    x++;
    return tmp;
  }
};

3.2 赋值运算符的重载

类会自动构建赋值运算符,而类内建的赋值运算符返回 *this,故用户自己定义的赋值运算符也应该返回 *this

利用赋值运算符重载特性,可实现多种语义的赋值,常见的:

浅拷贝赋值

由缺省的赋值运算符完成,会按成员声明顺序逐一调用成员的赋值运算。当成员是指针时,被赋值对象与赋值对象共用一个资源

深拷贝赋值

由用户定义的的赋值运算符完成。该函数不需要逐一调用非指针成员的赋值运算(但建议按声明顺序逐一赋值)。

当成员是指针时,需要释放已拥有资源,复制赋值对象对应的资源,并指向该资源的副本,即被赋值对象拥有赋值对象资源的副本。

3.3 下标运算符

C++ 规定,下标运算符 [] 必须以成员函数的形式进行重载。

class IntArray {
  private:
    int *a;
    int n;
  public:
   IntArray(int n=1):n(n) {…}
   IntArray(const IntArray& other) {…}
   ~IntArray() {…}
   int& operator[](int i) {
     if (i>=0 && i < n) {
       return a[i];
     }
     throw std::out_of_range("out of range");
   }
   const int& operator[](int i) const{
     if (i>=0 && i < n) {
       return a[i];
     }
     throw std::out_of_range("out of range");
   }
};

在实际开发中,我们应该同时提供以上两种形式,这样做是为了适应 const 对象,因为通过 const 对象只能调用 const 成员函数,如果不提供第二种形式,那么将无法访问 const 对象的任何元素。

3.4 函数调用运算符

函数调用运算符 () 只能重载为成员函数。通过对一个类重载 () 运算符,可以使该类的对象能像函数一样调用。

重载 () 运算符的一个常见应用是,将重载了 () 运算符的结构体作为自定义比较函数传入优先队列等 STL 容器中。

struct student {
  string name;
  int score;
};

struct cmp {
  bool operator()(const student& a, const student& b) const {
    return a.score < b.score || (a.score == b.score && a.name > b.name);
  }
};

// 注意传入的模板参数为结构体名称而非实例
priority_queue<student, vector<student>, cmp> pq;

3.5 友元函数

运用类外定义,实现自己定义的类的读入、输出能重载是常见的事,但有时候类的成员是 private 的,外部函数无法访问,这又该如何解决?

可以使用友元函数,在之前加上 friend 关键字。类内部声明 friend 普通函数,函数虽然不属于类,但却可以访问类的变量及函数(包括私有的)。也可以在类外面声明友元函数。单函数都需要在类外实现。

其他类的成员函数也可以声明成友元函数,但有时一个一个声明太过麻烦,可以直接声明友元类。