C++中的基础概念
C/C++编译流程
以 Linux 环境下 C/C++ 语言的编译过程为例,讲解其编译过程。首先编写以下 hello.c 程序:
// hello.c
#include <stdio.h>
int main(){
printf("hello world!\n");
}
编译过程只需
$ gcc hello.c # 编译
$ ./a.out # 执行
hello world!
上述GCC命令其实依次执行了四步操作:
- 预处理(Preprocessing)
- 编译(Compilation)
- 汇编(Assemble)
- 链接(Linking)
如下图所示:
预处理
预处理用于将所有的 #include
头文件以及宏定义替换成其真正的内容,预处理之后得到的仍然是文本文件,但体积会大很多。
编译
这里的编译不是指程序从源文件到二进制的全部过程,而是指将程序转换成特定汇编代码(Assembly Code)的过程,生成的仍然是代码文本。
汇编
汇编将汇编代码转换成机器码,这一步产生的文件叫做目标文件,是二进制格式。
链接
链接过程将多个目标文件以及所需的库文件链接成最终的可执行文件。
类(Class)
构造函数
构造函数不能为虚函数,原因有以下几点:
- 从存储角度,虚函数对应一个指向 vtable 虚函数表的指针,可是这个指针其实是存储在对象的内存空间中。如果构造函数是虚的,就需要通过 vtable 来调用,可是对象还没有实例化,所以构造函数不能是虚函数。
- 不存在使用到虚构造函数的使用场景,因为在对象构造时调用者的信息是完整且透明的。
注意,在这种场景下:
Node n;
类 Node 的构造函数也是会执行的。
默认构造函数
如果用户定义的类中没有显式的定义任何构造函数,编译器就会自动为该类型生成默认构造函数,称为合成的构造函数(synthesized default constructor)。
拷贝构造函数
拷贝构造函数,又称复制构造函数,是一种特殊的构造函数,它由编译器调用来完成一些基于同一类的其他对象的构建及初始化。其形参必须是引用。
应用场景
在 C++ 中,下面三种对象需要调用拷贝构造函数(有时也称“复制构造函数”):
- 一个对象作为函数参数,以值传递的方式传入;
- 一个对象作为函数返回值,以值传递的方式从函数返回;
- 一个对象用于给另外一个对象进行初始化(常称为赋值初始化);
使用方式
拷贝构造函数必须以引用的形式传递(参数为引用值)。其原因如下:当一个对象以传递值的方式传一个函数的时候,拷贝构造函数自动的被调用来生成函数中的对象。如果一个对象是被传入自己的拷贝构造函数,它的拷贝构造函数将会被调用来拷贝这个对象这样复制才可以传入它自己的拷贝构造函数,这会导致无限循环直至栈溢出(Stack Overflow)。
构造函数是可以重载的,因为在进行实例初始化时可能需要提供的形参个数不同。
析构函数
当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。
-
析构函数不可重载,因为没有参数。
-
析构函数可以是虚函数,因为我们往往通过基类的指针来销毁对象。如果析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。
继承
两种关键关系
- 整体部分关系:一辆车有轮子、引擎。聚合/组合都属于整体部分关系。
- 特殊一般关系:管理者是员工。
多重继承
C++ 允许多重继承(存在多个直接基类)。
class Temporary{ /* ... */ };
class Secretary : public Employee { /* ... */ };
class Tsec : public Temporary, public Secretary { /* ... */};
class Consultant : public Temporary, public Manager {/* ... */};
抽象类
一些类代表的抽象概念不允许有实例存在。比如,形状只有在有些类继承他时才有意义。
class Shape {
public:
virtual void rotate( int ) { error("invalidate invoke"); }
virtual void draw( ) { error("invalidate invode"); }
};
当调用到该函数时,只能报错。一种解决方式是把虚函数定义为纯虚函数。
class Shape {
public:
virtual void rotate( int ) = 0;
virtual void draw( ) = 0;
};
有一个或者多个抽象函数的类称为抽象类,这种类不允许有实例出现。
三种控制级别
Public
一般用户和友元能够进行访问。
Protected
派生类成员函数和友元能够进行访问。
Private
自身成员函数和友元能够进行访问。
继承访问控制
仅影响派生类所继承的基类成员在派生类的用户代码中的能见度。这里的派生类用户代码指外界任何函数与 D 的派生类。
class B {
/* … */
};
class D : B {
/* … */
};
Public继承
不改变基类成员的能见度。如 B 的 public 成员可被任何函数访问。B 的 protected 成员只可被 D、D 的友元、D 的派生类及其友元访问。
Protected继承
将 B 之 public 成员的能见度降低为 protected,其余不变。如 B 的 public、protected 成员只可被 D、D 的友元、D 的派生类及其友元访问。其他任何函数无法访问 B 的这些成员。
Private继承
B 的所有成员能见度降低为 private。如 B 的 Public、Protected 成员只能被 D、D 的友元访问。D 的派生类和其他任何函数无法访问 B 的这些成员。
封装
定义
封装,即隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别。
多态
定义
多态(英语:polymorphism)指为不同数据类型的实体提供统一的接口。允许将子类类型的指针赋值给父类类型的指针。计算机在得到一个指针时,能够根据该指针值的类型而非指针的类型进行调用,即赋予了子类能够被归类为父类的能力。
实现多态的关键机制则是虚函数。
例子
比如有动物(Animal)之类别(Class),而且由动物继承出类别鸡(Chicken)和类别狗(Dog),并对同一源自类别动物(父类别)之一消息有不同的响应。如类别动物有“叫()”之动作,而类别鸡会“啼叫()”,类别狗则会“吠叫()”,则称之为多态。
动态链接库
定义
动态链接库(Dynamic Link Library, DLL),是一个可以被其他应用程序共享的模块。
动态链接库文件(DLL文件)与可执行文件(EXE文件)非常类似,区别在于 DLL 虽然包含了可执行代码却不能单独执行,而应由 Windows 程序直接或者间接调用。
背景
DLL 的最初目的是节约应用程序所需的磁盘和内存空间。在一个传统的非共享库中,一部分代码简单地附加到调用的程序上。如果两个程序调用同一个子程序,就会出现两份那段代码。相反,许多应用共享的代码能够切分到一个 DLL中,在硬盘上存为一个文件,在内存中使用一个实例(instance)。DLL 的广泛应用使得早期的视窗能够在紧张的内存条件下运行。
绑定
动态绑定
动态绑定是指在执行期间(非编译期)判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。程序运行过程中,把函数(或过程)调用与响应调用所需要的代码相结合的过程称为动态绑定。
class Employee {
string first_name, family_name;
char middle_initial;
short department;
// ...
public:
virtual void print() const;
string full_name() const;
Employee(const string& n, int d);
// ...
};
class Manager : public Employee {
list<Employee*> group;
short level;
// ...
public:
void print() const;//重置,虚函数
Manager(const string& n, int d.
int lvl);
// ...
};
// example of dynamic binding
void printall( Employee * p){
p -> print( );
}
// example of dynamic binding
Employee e1("张三", 1);
Manager m1("李四", 1, 2);
Employee *p1 = &e1, *p2 = &m1;
printall( p1 ); // Employee::print
printall( p2 ); // Manager::print
静态绑定
静态绑定发生在编译期,因此不能利用任何运行期的信息。把函数(方法或者过程)调用与响应调用所需的代码结合的过程称之为静态绑定。
class Employee {
string first_name, family_name;
char middle_initial;
short department;
// ...
public:
virtual void print() const;
string full_name() const;
Employee(const string& n, int d);
// ...
};
class Manager : public Employee {
list<Employee*> group;
short level;
// ...
public:
void print() const;//重置,虚函数
Manager(const string& n, int d.
int lvl);
// ...
};
// example of static binding
Employee e1("张三", 1);
Manager m1("李四", 1, 2);
// ...
e1.print();
m1.print();
在上面代码中,程序对于两个变量的所属类型十分清楚,所以程序也明白应当调用所对应类型的对应函数,因为在编译期,程序知道其类型。
虚函数与纯虚函数
虚函数
在某基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数,用法格式为:virtual 函数返回类型 函数名(参数表) {函数体}
;实现多态性,通过指向派生类的基类指针或引用,访问派生类中同名覆盖成员函数。
class A{
public:
virtual void print(){cout<<"This is A"<<endl;}
};
class B : public A{
public:
void print(){cout<<"This is B"<<endl;}
};
虚函数与非虚函数都可以在子类中进行重载,但它们的区别在于:
- 非虚函数中,通过基类指针(指向了子类)调用重载函数时,实际上调用的仍然是基类的函数,未实现多态;
- 类的对象内部会有指向类内部的虚表地址的指针。通过这个指针调用虚函数。虚函数的调用会被编译器转换为对虚函数表的访问,从而不会导致访问基类的虚函数。注意虚表是每一个类一个,而指向虚表的指针是每一个对象一个。
也就是,虚函数实现了运行时确定调用而非编译时确定调用,这为 C++ 实现多态提供了支持。基类中的虚函数可以有自己的实现,不能在基类中实现的函数为纯虚函数。
纯虚函数
纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。
class A {
public:
A();
virtual void f()=0;
};
非虚函数
当在基类中定义的函数为非虚函数时,基类类型的指针值为子类类型时,运行调用该函数结果仍为基类中的函数,即未实现多态。
若在基类中定义的函数为虚函数,基类类型指针为子类类型时,调用该函数,结果为子类中的函数,实现了多态。
#include<iostream>
using namespace std;
class A{
public:
int a;
A(){
a = 5;
}
void f1(){
cout<<"f1"<<endl;
}
virtual void f2(){
cout<<"f2"<<endl;
}
};
class B:public A{
public:
B(){
this->a = 5;
}
void f1(){
cout<<"f3"<<endl;
}
void f2(){
cout<<"f4"<<endl;
}
};
int main(){
A* ptr;
B b;
ptr = &b;
ptr->f1();
ptr->f2();
}
这段代码的运行结果为“f1 f4”。
重载、覆盖与隐藏
C++ 规定,当一个成员函数被声明为虚函数后,其派生类中的同名函数都自动成为虚函数。因此,在子类重新声明该虚函数时,可以加,也可以不加,但习惯上每一层声明函数时都加 virtual,使程序更加清晰。
重载( Overloading )
- 具有相同的作用域,不涉及到继承,即同一个类中;
- 函数名相同;
- 参数类型不同;
- virtual 关键字可有可无。
覆盖(Override)
覆盖指子类虚函数重新实现了父类的虚函数,其特征是:
- 不同的作用域(分别位于派生类和基类中,即虚函数的重写) ;
- 函数名相同;
- 参数列表完全相同;
- 基类函数必须是虚函数。
覆盖仅仅针对虚函数。
隐藏(Hide)
- 如果基类不是虚函数,子类又重新定义了该函数,无论参数相同不相同都是隐藏。
- 如果基类是虚函数, 参数相同就是覆盖,参数不同就是隐藏。
虚函数表
注意的是,编译器会为每个有虚函数的类创建一个虚函数表,该虚函数表将被该类的所有对象共享。类的每个虚成员占据虚函数表中的一行。
虚函数是通过虚函数表来实现的。简称为 V-Table。在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。这样,在有虚函数的类的实例中分配了指向这个表的指针的内存。
友元函数
定义
在面向对象编程中,友元函数(friend function)是一个指定类(class)的“朋友”,该函数被允许访问该类中 private、protected、public 的数据成员。普通的函数并不能访问这些数据,然而宣告一个函数成为一个类的友元函数则被允许访问这些数据。
一个友元可以是一个全局函数,也可以是另外一个类的成员函数,也可以是另外一个类。友元的加入破坏了类的封装和信息隐藏,所以宁缺勿滥!
代码示例
当一个函数需要访问两个不同类型对象的私有数据成员的时候,可以使用友元函数。有两种使用的方式:
- 该函数作为全域函数,在两个类中被宣告为友谊函数
- 作为一个类中的成员函数,在另一个类中被宣告为友谊函数
#include <iostream>
using namespace std;
class Bezaa; // Forward declaration of class Bezaa in order for example to compile.
class Aazaa
{
private:
int a;
public:
Aazaa() { a = 0; }
void show(Aazaa& x, Bezaa& y);
friend void ::show(Aazaa& x, Bezaa& y); // declaration of global friend
};
class Bezaa
{
private:
int b;
public:
Bezaa() { b = 6; }
friend void ::show(Aazaa& x, Bezaa& y); // declaration of global friend
friend void Aazaa::show(Aazaa& x, Bezaa& y); // declaration of friend from other class
};
// Definition of a member function of Aazaa; this member is a friend of Bezaa
void Aazaa::show(Aazaa& x, Bezaa& y)
{
cout << "Show via function member of Aazaa" << endl;
cout << "Aazaa::a = " << x.a << endl;
cout << "Bezaa::b = " << y.b << endl;
}
// Friend for Aazaa and Bezaa, definition of global function
void show(Aazaa& x, Bezaa& y)
{
cout << "Show via global function" << endl;
cout << "Aazaa::a = " << x.a << endl;
cout << "Bezaa::b = " << y.b << endl;
}
int main()
{
Aazaa a;
Bezaa b;
show(a,b);
a.show(a,b);
}
常量
非指针常量
定义方式为
const 类型 ID = 初值。
- 当类型不是 T* 时,不允许修改的是 “ID” 的值。
- 当类型是 T* 时,不允许修改的是 *ID 及 ID[下标] 的值,而不是 “ID” 的值。
不管怎么样,不允许更改的都是值而非指针的值。
指针常量
定义方式为
T* const ID = 初值
不允许修改的是 “ID” 的值,而不是 “*ID” 及 “ID[下标]” 的值。
所以,const 在前表示值不能变,而 const 在后表示指针不能变。
引用
引用是另一个已经存在对象的别名,二者代表同一对象(同一块存储空间)。
- 在声明时必须初始化。
- 不能为空值。
- 此后不能再引用其他对象。
访问控制
只读成员
如何表示一个操作是“只读”而不修改数据成员?向外界表明,const 类型的成员函数不改变任何数据成员的值。
int Date::year( ) const
{
return y;
};
注意:一个 const 成员函数能够被 const 和非 const 对象同时调用,而一个非 const 成员函数无法被 const 对象进行调用。
操作符重载
操作符(Operator)是对某个操作/计算的简化表示。对于C++中的用户自定义类型(Class),操作符的具体内涵可以被重新定义,这被称为操作符重载。
基本前提
- 不能改变操作符的结构规则(包括语法结构、操作数个数、优先级、结合律等);
- 不能与其他类型用操作符表示的操作之间存在冲突(即在某一特定表达式中,某个操作符应具有明确的、唯一的含义)。
不允许重载的操作符
::、 .、 .*、 ?:、 sizeof、 typeid. C++不允许定义语言本身未定义的操作符,或组合定义操作符,例如**。
模板
引入模板的目的:对结构特征和行为特征相似,但数据类型不能保证相同的一组类(或函数)进行高一级的抽象,以提高程序的重用性和规格化程度。
一个类是一组对象的抽象;一个类模板是对一组类的抽象。类的实例是对象,类模板的实例是类。
实参
合法的模板实参种类:
- 一个类型。
- 一个常量表达式。
对于以下模板:
template<class T, int i>
class Buffer{
T v[i];
int sz;
public:
Buffer():sz(i) { }
// …
};
这样使用是正确的:
Buffer<double, 10> d_buf;
const int csz = 100;
Buffer<char, csz+10> c_buf;
这样使用是错误的:
void f ( int size ){
Buffer<char, size> eBuf;
}
异常处理
对错误的处理
- 对于不能在本地处理的错误,进行报告;
- 对于不是在本地检测出来的错误,进行处理。
错误处理举例
char to_char(int i)
{
if (i < CHAR_MIN || CHAR_MAX < i)
throw Range_error(i);
// 出现了异常,向调用者报告
// 以异常方式返回调用者
return i;
}
void g(int i) { // to_char()的使用者
try { // test-block
// 在此期间若出现异常,由下面的 catch 处理
// 异常时从to_char跳出,由调用方处理
char c = to_char(i);
}
// 当“有人throw”出Range_error时,激活该异常处理
catch (Range_error) { // exception-handler
cerr << "oops" << "\n";
}
}
#include <iostream>
int main(int argc, char *argv[]) {
/* An annoying "Hello World" example */
for (auto i = 0; i < 0xFFFF; i++)
cout << "Hello, World!" << endl;
char c = '\n';
unordered_map <string, vector<string> > m;
m["key"] = "\\\\"; // this is an error
return -2e3 + 12l;
}
F&Q
-
C 当中的 static 关键字作用是什么?
stackoverflow上的答案如下:
主要有两个作用:
- 一个函数中的静态变量在多次调用之间保持它的值;
- 一个静态的全局变量或者函数仅在它声明的文件中可见(作为一种访问控制工具),因为开发者仅仅想暴露其想暴露的接口。
-
C 当中的 volatile 关键字是什么?
如果将变量定义为
volatile
类型,意味着该变量可能随时被修改,每次使用时应重新读取,而非使用保存在寄存器中的值(防止其他线程在内存中对其做了修改)。