历史归档 · C++ 与算法

C++:一些概念

抽象类、虚函数、内存管理、继承与模板等 C++ 基础概念的集中整理。

15 分钟阅读

1.抽象类

带有纯虚函数的类称为抽像类,不可实例化,只能作为接口使用

作用:可定义指向抽象类的指针,此指针可指向其派生类,进而实现多态


2.纯虚函数

一个在基类中声明的虚函数,在基类中没有定义具体的操作内容,要求各派生类根据需要定义自己的版本。

virtual function_type function_name(arguments) = 0;

3.构造函数的delete和default

myClass() = default; //默认存在构造函数
myClass() = delete; //删去
  • 一旦程序员实现了这些函数的自定义版本,编译器将不会再生产默认版本
  • 限制默认构造函数生成,典型的是静止使用拷贝构造函数
  • 可以避免不必要的隐式类型转换(调用的是整形构造函数)
  • delete可以删去operator new void * operator new(std::size_t) = delete;
  • 析构函数也可被delete——限制自定义类型在栈上或静态储存区的构造

4.Override/Overload/Overwrite 覆盖/重载/重写

override

标识符,非关键字;只有在虚函数的环境中才有意义;不同的作用域(覆盖)

  1. 向代码读者展示了这是一个虚拟的方法,它覆盖了基类的虚拟方法
  2. 强制编译器检查这个函数是否正确的覆盖了基类的函数

overload

重载:相同的作用域;函数名词相同;参数的类型或顺序不同

overwrite

派生类屏蔽了与其同名的基类函数


5.虚函数的作用

若不是虚函数,不管基类指针指向哪个派生类的对象,调用时都会调用基类中定义的函数。

虚函数:动态绑定的基础


6.malloc 和 free

void*类型可通过类型转换,强制转化为任何类型的指针

与new & delete的区别

  1. new可以按照变量类型自动分配所需要的空间长度
  2. new可以自动返回正确的指针类型
  3. new会自动调用构造函数,delete自动调用析构函数;但malloc和free不会
  4. new申请的内存在自由储存区(栈或静态储存区);malloc分配的内存在堆区
  5. new内存不足时会抛出异常,而malloc返回NULL

如何判断new()是否成功

try
{
int *p = new int[1e9];
}
catch (const bad_alloc & e)
{
cout << "failed" << endl;
//return(-1);
}
int *p = new (nothrow) int[1e9];

if (p == 0)
cout << "failed" << endl;

delete[] p;

delete[ ]怎么知道需要释放空间的大小

申请空间的首位置的前4个字节记录了申请空间的大小。(不一定是4个字节,具体需要跟具体的内存对齐相关)在Visual Studio上的代码如下

using namespace std;

class A
{
static int i;
public:
A() = default;

~A()
{
cout << "delete the " << ++i << " A." << endl;
}
};
int A::i = 0;

A* test()
{
return(new A[6]);
}

int main()
{
A *p = test();
int address = (int)p - 4;

cout << *(int *)address << " A will be deleted" << endl;

delete[] p;
return(0);
}

运行结果为:

6 A will be deleted
delete the 1 A.
delete the 2 A.
delete the 3 A.
delete the 4 A.
delete the 5 A.
delete the 6 A.

7.防止头文件被重复包含或引用

  1. #pragma once——保证头文件只被编译一次(在头文件最开始的时候加)
  2. 条件编译——解决多重包含问题 #ifndef _HEADNAME_H #define _HEADNAME_H #endif

8.include<> vs include ""

  • <>引用的是编译器类库路径里面的头文件
  • “”引用的是程序相对路径(当前目录下)的头文件,如果找不到就到编译器的类库文件中找

9.class和struct

  1. 默认访问权限和继承权限不同。class——private;struct——public
  2. class可以用于表示模板类型,struct不行

10.C/C++程序执行过程

  1. 预处理——对源文件进行宏替换,条件编译,头文件展开,去除注释
  2. 编译——将上一部处理后的文件进行语法分析,语义分析,以及优化后产生汇编代码文件
  3. 汇编——将编译出的代码翻译成机器指令
  4. 链接——将所有文件链接到一起生成一个可执行的程序

链接可分为静态和动态:

  • 静态链接/lib——lib中所有的指令都被直接包含在最终生成的exe文件中
  • 动态链接/dll——dll不必包含在最终的exe中,exe文件执行可动态引用或卸载dll,这样可以节省内存。dll和exe相互独立,只要接口不变,更换dll就不会影响,故极大提高了扩展性和可维护性。

11.内联函数

为了解决函数调用的效率问题

程序在编译器编译时,编译器将程序中出现的内敛函数的调用表达式用内内联函数的函数体表示。而对于其他的函数都是运行时才被替换。

空间换时间

inline int add(int x, int y)
{
return(2 * x + y);
}

12.缓冲区/缓冲

在内存空间预留了一定的储存空间,用来缓冲输入输出的数据

用在输入输出设备和CPU之间

  • 全缓冲:当填满标准I/O缓存后才执行实际的I/O操作(例如对磁盘的读写)
  • 行缓冲:遇到换行符才进行实际I/O操作(例如键盘输入数据);endl,flush
  • 不带缓冲:标准出错情况(stderr),使错误信息尽快显示出来

13.有继承时,析构函数必须为虚函数

class Base
{
public:
Base() = default;

virtual ~Base()
{
cout << "Base's destructor called" << endl;
}
};

class Derived : public Base
{
public:
Derived() = default;

~Derived()
{
cout << "Derived's destructor called" << endl;
}
};

int main()
{
Base *p = new Derived();
delete p;
return(0);
}

输出 Derived's destructor called Base's destructor called

如果基类析构函数非虚,则只调用基类析构函数,不调用派生类析构函数,造成内存泄漏 Base's destructor called


14.深拷贝和浅拷贝

深拷贝会在堆上申请空间来储存数据

当数据成员有指针时,必须使用深拷贝。(有指针时,浅拷贝两个指针会指向同一地址)


15.指针函数和函数指针

指针函数

int* fun(int x, int y)
{
int *p = new int;
*p = x + y;
return(p);
}
//本质是一个函数,返回值为指针

函数指针

int(*fun)(int x, int y);

int add(int x, int y)
{
return(2 * x + y);
}

int main()
{
fun = add;
cout << (*fun)(2, 2) << endl;
}
//本质是一个指针,指向一个函数

16.内存管理

堆、栈、自由储存区、全局/静态 储存区 和 常量储存区

自由储存区:抽象概念——凡是由new()申请的空间就叫自由储存区

new()除了调用系统默认的构造函数外,还会给基本数据类型和指针类型成员用0赋值。

所谓内存管理,用的就是new和delete。请见第1页第6项。

  1. 构造函数和析构函数能否抛出异常

两者都可以抛出异常,但析构函数不推荐抛出异常

构造函数:创建对象要两个操作——分配内存和调用构造函数。当分配内存出错就会抛出异常;用 new 的话构造函数出现异常不会造成内存泄漏

析构函数

  • 如果抛出异常则很可能会导致内存泄漏,因为抛出异常后的代码不会执行
  • 通常发生异常时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

Solutions

  • 如果某个操作可能会抛出异常,class应提供一个普通函数(而非析构函数),来执行该操作。目的是给客户一个处理错误的机会。
  • 如要在析构函数中抛出异常,则用try catch处理异常,不可将异常抛析构函数之外

  1. 构造函数和析构函数能否调用虚函数
  • 从语法上来看,可以的。编译器不会报错,程序正常运行
  • 从效果上来看,达不到基类使用其派生类函数声明的效果

派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。同样,进入基类析构函数时,对象也是基类类型。达不到多态的效果。


  1. mutable关键字

mutable 关键字与 const 关键字相反,被 mutable 修饰的变量永远处于可变状态。在一个被 const 修饰的类/结构的实例中 或者在这个实例的const函数中,mutable变量的值也是可以改变的。(注意 mutable 不可与 static 关键字同用)

下面这段代码中,const 实例中,我们可以改变其 mutable 成员变量。

struct Test
{
int a = 0;
mutable int b = 0;
Test(int A, int B) :a(A), b(B) {};
};

int main()
{
const Test t(1, 1);
//t.a = 2;  wrong!
t.b = 2;	//right
return(0);
}

此外,在被 const 修饰的类的成员函数中。在 const 的成员函数里面修改一些跟类状态无关的数据成员,那么这个数据成员就应该被 mutalbe 来修饰。

class Test
{
public:
Test(int A = 0, int B = 0)
{
a = A;
b = B;
count = 0;
}

int getCount() const
{
return(++count);
}

private:
int a;
int b;
mutable int count;
};

int main()
{
Test t;
cout << t.getCount() << endl; //out: 1
cout << t.getCount() << endl; //out: 2
cout << t.getCount() << endl; //out: 3
return(0);
}

通过用 const 关键字来避免在函数中错误的修改了类对象的状态。并且在所有使用该成员函数的地方都可以更准确的预测到使用该成员函数的带来的影响。而 mutable 则是为了能突破 const 的封锁线,让类的一些次要的或者是辅助性的成员变量随时可以被更改。(来源请点这里


  1. 泛型编程

C++ 通过 函数模板 和 类模板 来实现泛型编程。这节泛型编程的来源于这篇博客

1. 函数模板

  • 函数模板是C++中一种重要的代码复用方式
  • 一种特殊函数,可通过不同类型进行调用
  • 通过 template 关键字来声明模板
  • 通过 typename 关键字来定义模板类型
template<typename T>

void Swap(T&a, T&b)
{
T temp = a;
a = b;
b = temp;
}

int main()
{
string a = "Swap", b;
Swap(a, b);
cout << "a:"  << a << "\tb:" << b << endl;
}

有自动调用(编译器自己推断)和显示调用(告诉编译器类型)

Swap(a, b);    //自动调用
Swap<string>(a, b);    //显示调用

下面这个是数组排序和打印的例子

template<typename T>
void Sort(T a[], int len)
{
for (int i = 0; i < len; i++)
{
for (int j = 0; j < i; j++)
{
if (a[i] < a[j])
{
T temp = a[i];
a[i] = a[j];
a[j] = temp;
}
}
}
}

template<typename T>
void display(T a[], int len)
{
for (int i = 0; i < len; i++)
cout << a[i] << " ";
cout << endl;
}

int main()
{
int nums[] = { -1, 5, 9,-5,2 };
Sort(nums, sizeof(nums) / sizeof(int));
display(nums, 5);

string s[] = { "C++","Java","Python","Go","PHP" };
Sort(s, 5);
display(s, 5);
}

Q:为什么函数模板为什么能够执行不同类型的参数 A:因为编译器对函数模板进行了二次编译

  1. 第一次编译——检查函数模板本身有没有语法错误
  2. 第二次编译——找到调用函数模板的代码,然后通过代码的真正参数来生成真正的函数

故函数模板其实只是一个模具,当被调用时,编译器就会生成真正的函数.

多参数函数模板

template<class T1, class T2, class T3>

T1 add(T2 a, T3 b)
{
return(static_cast<T1>(a + b));
}

int main()
{
int a = 3;
float b = 0.14;

double result = add<double>(a, b);
float answer = add<float, int, float>(a, b);
cout << result << endl; //3.14
cout << answer << endl; //3.14
return(0);
}

重载函数模板

  • 函数模板可以像普通函数一样被重载
  • 函数模板不接受隐式转换
  • 当有函数模板,以及普通重载函数时,编译器会优先考虑普通函数
  • 如果普通函数的参数无法匹配,编译器会尝试进行隐式转换,若转换成功,便调用普通函数
  • 若转换失败,编译器便调用函数模板
  • 可以通过空模板实参列表来限定编译器只匹配函数模板 int c = add<>(a,b)

2. 类模板

  • 和函数模板一样,将泛型思想应用与类
  • 编译器对模板函数和类模板的处理方式相同,都是进行二次编译
  • 类模板通常应用于数据结构方面——使得类的实现不关注与数据成员的类型,而去更多的关注需要实现的功能。

使用方法:

  • 通过 template 来声明
  • 通过 typename 来定义模板类型
  • 定义对象时,必须指定类模板类型(因为编译器无法推导类型)
string operator - (string&s1, string&s2) {
return("Invalid operation!");
}

template<typename T>

class Operator
{
public:
T add(T a, T b) {
return(a + b);
}

T minus(T a, T b) {
return(a - b);
}

T multiply(T a, T b) {
return(a * b);
}

T divide(T a, T b) {
return(a / b);
}
};

int main()
{
Operator<string>* p = new Operator<string>;
cout << p->minus("C++", "C") << endl;
//out: "Invalid operation!"
delete p;
p = nullptr;

Operator<int>* p2 = new Operator<int>;
cout << p2->multiply(3, 3) << endl;
//out: 9
delete p2;
p2 = nullptr;
return(0);
}

类模板的工程应用:

  • 类模板必须在头文件中定义
  • 类模板的成员函数不能分开在不同的文件中实现
  • 类模板外部定义的成员函数,和模板函数一样,还需要加上模板template 声明,以及结构体声明

多参数类模板

类模板特化

  • 表示可以存在多个相同的类名,但是模板类型都不一致(类似于函数重载)
  • 特化类型有完全特化和部分特化两种类型
  • 完全特化表示显示指定类型参数,模板声明只需写成template<>,并在类名右侧指定参数

数值型参数模板

  1. 右值引用

左值和右值的区别

  • 是否能获取地址
  • 左值是非临时变量(通常是程序中有名字的变量);右值是临时性的对象。

先说一下左值引用,顾名思义,左值引用就是对左值的引用,其代码可以是 int &b = a; 在定义左值引用时,等号右边必须是可以修改的左值。以下三种情况不行

  • a为cosnt—— const int a = 1;
  • 等号左边为常量(右值)——int &b = 1; //WRONG!
  • 另一种形式的右值——int &b = a1 + a2; //WRONG!

再说右值引用,相对的,右值引用就是对右值的引用。其代码可以是:

int fun(int n)
{
return(n*n);
}

int main()
{
int && a = 1;
int && b = fun(6);
int && c = const_cast<int&&>(max(1, 6));
cout << a << endl;  //1
cout << b << endl;  //36
cout << c << endl;  //6
return(0);
}

  1. std::move

使用引用来减少复制产生的内存开销。在上一节代码中的 Swap() 函数,对 temp 进行 ‘=’ 赋值操作时,实际上复制了原有对象的内存。可我们只需要对象之间的移动而不是复制,那么就可以使用 std::move

void Swap(vector<int> & a, vector<int> & b)
{
vector<int> temp = move(a);
a = move(b);
b = move(temp);
}

int main()
{
vector<int> a = { 1,2,3 };
vector<int> b = { 4,5,6 };
Swap(a, b);
//456
for (int n : a)
cout << n;
cout << endl;
//123
for (int n : b)
cout << n;

return(0);
}

  1. strcat

将源字符串的副本附加到目标字符串上,目标字符串中的终止空字符由源字符串的第一个字符覆盖,并将这两个字符串连接形成的新字符串,末尾包含一个空字符。

#include <iostream>
#include <string>
#pragma warning(disable : 4996)
using namespace std;

int main()
{
char a[20] = "Hello ";
char b[20] = "world!";
strcat(a, b);
cout << a << endl;
}

  1. #define, templatetypedef

1. #define

1.1 简单的定义:define N 5

1.2 宏的函数定义

define MAX(a,b) a > b ? a : b 没有类型检查,比不上函数模板安全

可能得到与预期不同的结果,看下面两个代码

#define MIN(a,b) a < b ? a : b

int main()
{
printf("%d", MIN(1, 2) + 3);  //1
printf("\n%d", min(1, 2) + 3); //4
return(0);
}
#define INTPTR1 int*

int main()
{
INTPTR1 p1, p2;
cout << typeid(p1).name() << endl;  //int *
cout << typeid(p2).name() << endl;  //int
return(0);
}

1.3 宏的多行定义

define可以替代多行的代码,例如MFC中的宏定义。关键是要在每一个换行的时候加上一个“/“。

1.4 条件编译

避免头文件被重复包含,如一下在 pch.h 头文件中的代码

#ifndef PCH_H
#define PCH_H

// TODO: add headers that you want to pre-compile here

#endif //PCH_H

2. template

请见第20阶——泛型编程

3. typedef

typedef是在计算机编程语言中用来为复杂的声明定义简单的别名

4. #define 与 typedef 的区别出处

  • 执行时间不同——宏展开是在预处理阶段,而typdef是在编译阶段
  • 作用域不同——define的作用域是全局,而typedef只能在其声明的定义域内使用
  • #define可以做条件编译
  • 对指针的操作不同(见1.2)

  1. extern

C++ 中 extern 有两种用法

  • 修饰变量或函数,告诉编译器可以去其他文件中找。extern 是声明不是定义,即不分配存储空间。
  • C++语言在编译的时候为了解决函数的多态问题,会将函数名和参数联合起来生成一个中间的函数名称,而C语言则不会,因此会造成链接时无法找到对应函数的情况,此时C函数就需要用extern “C”进行链接指定,这告诉编译器,请保持我的名称,不要给我生成用于链接的中间函数名。