cpp与面向对象编程

前言

最近学了两三个月C++,对于C++中的OOP编程有了一些心得体会,于是在这里写一篇教学性质的文章。因为还在学习,所以(很可能)有谬误,还请不吝指出,给大家磕头了((

C++基础知识

引用

相信大家已经对指针(pointer)不陌生了,引用(reference)与指针非常类似,但是有以下特点:

  1. 指针可以为nullptr;而引用在语义上不能为空(注意这并不意味着引用不会发生Use After Free的情况,在写码时要注意被引用对象的生命周期不能短于引用的生命周期)
  2. 指针是值,可以修改;而引用相当于一个“别名”,一旦绑定不可修改
  3. 在使用指针指向的对象时,首先要解引用(即使用*);而引用可以被视作指向的对象本身,不需要使用*运算符
  4. 引用包含左值引用和右值引用,在对象构造时有着重要作用

由于在C++中,值可以被隐式转换为对其的引用,因此我们要注意变量实际的类型。

左值引用

左值(lvalue)就是在内存当中存储的值,之所以被称作左值是因为它常常在赋值号的左边。

比较简单的理解,左值就是能取内存地址的值。左值引用就是对左值的引用。

当然,左值不一定是可修改的,例如字符串常量是左值,但不可被修改。

1
2
3
4
5
6
7
8
9
#include<iostream>
void foo(int& i){
i++;
}
int main(){
int a=3;
foo(a); //虽然a是int型,但是此处foo只接受int&类型的参数,所以a被隐式转换为对a的引用
std::cout<<a<<std::endl;
}

右值引用

右值(rvalue)就是“不是左值的值”,常在赋值号的右边。比如字面量、表达式的运算结果等。右值包含亡值(xvalue)和纯右值(prvalue),前者指被临时存储但是即将被抛弃的对象(例如表达式的求值结果),而纯右值为不是亡值的右值,例如字面量。

乍一看右值引用似乎有些鸡肋,但右值引用主要用于对象的移动上,亡值有着“即将被抛弃”的语义,可以很好描述对象的移动语义(所有权的转移)。

在C++中,右值引用使用两个&号表示。右值引用是可以被更改的。

1
2
3
4
5
6
7
8
#include<iostream>
int main(){
int&& a = 3;
std::cout<<a<<std::endl; //3
a++;
std::cout<<a<<std::endl; //4
return 0;
}

常量左值引用

类似于指针,我们在左值引用前加上const就能声明这个引用的对象不可被更改。特别地,常量左值引用可以指向一个右值。在C++中,对于任何显式引用临时值的语句,临时值的生命周期都会被延长至引用的生命周期。(For any statement explicitly binding a reference to a temporary, the lifetime of all temporaries in the statement are extended to match the lifetime of the reference.)

1
2
3
4
5
6
7
8
9
#include<iostream>
void foo(const int& i){
std::cout<<i<<std::endl;
//在这里不能更改i
}
int main(){
foo(3); //可以传入右值
return 0;
}

OOP(Object-Oriented Programming)

对象

对象(object),比较直观的理解就是一个“东西”。面向对象编程,就是把我们要处理的逻辑、事务都抽象为一个个不同“类”(class)的对象。为了方便我们的抽象,对象有着好用的特性(封装、继承、多态)。

自然地,每个对象拥有自己的属性和方法。比如我用一个类表示书,那么书的属性可能有:标题、内容、封皮颜色等,书的方法可能有更改内容、阅读等。一个对象的方法是与这个对象有关的操作(不一定更改这个对象)。在实践中,我们按需要定义一个类的属性和方法。

在C++中,我们称一个类包含的值为“成员”,包含的方法为“成员方法”,依然用上面的例子,在C++中,我们这样声明一个类:

1
2
3
4
5
class book {
std::string title; //成员
std::string content; //成员
void read(); //成员方法
};

我们这样实现一个类的方法

1
2
3
void book::read() {
std::cout<<content<<std::endl;
}

一个类只是描述了一类对象,并不描述一个具体的对象。例如apple类描述了苹果,但是我手中的苹果、别人手中的苹果、超市里的苹果亦有差别。我们将这样具体的对象称作类的实例(instance)。在C++中,我们用声明变量的方式得到对象的实例。

1
book b; //现在我拥有了一个变量b,为book类的实例

类似于C中的malloc和free,在C++中,我们使用operator new和operator delete处理在堆上的对象实例。

1
2
book* b = new book();
delete b;

至此,我们的类看起来似乎和结构体区别不大。下面我们就来介绍一下类与结构体不同的特性

封装

在一个类中,常有一些东西不应被其他类访问。例如一些辅助函数。在程序员使用我们写的类的时候,我们不希望这些东西对外访问给程序员造成“这是什么”的困惑,也不希望内部的一些方法被意外调用破坏对象的逻辑。我们可以通过设置这些成员的可见度实现对成员访问的限制。这样的思想体现了类的封装(encapsulation)。

在C++中,我们在成员面前加上访问修饰符即可实现对象成员“可见度”的定义。C++中的访问修饰符包含三种

1
2
3
4
成员的访问修饰符
private 仅对当前类可见
protected 对当前类和当前类的派生类可见
public 可见

我们将访问修饰符加在对象成员的前面进行修饰

1
2
3
4
5
6
7
8
class book {
public:
void read_title();
void read_content();
private:
std::string title;
std::string content;
};

这样做是因为(打印好的)书是不能被更改的,只能读,所以我们不希望外部代码更改我们书籍的title和content,于是将它设为private。(当然,将其设为const也有不能进行更改的语义,然而考虑到我们的book类可能需要读取书籍文件获取title和content,因此会涉及到成员的更改,不应设为const)

继承

类往往不是孤立的,类与类之间存在着一些联系,因此有了“继承”(inheritance)的概念。通过继承,派生类可以得到基类的属性和方法,从而提高代码复用性和可维护性。例如程序中包含两个类fruit和apple,显然apple是fruit的一种,因此apple应是fruit的派生类(derived class)。

这时我们注意到一个问题,当派生类继承基类(base class)时,基类成员的可访问性是怎样的?在派生类中,基类成员的可访问性自然如上节所讲。但是在外部类中呢?C++在继承时也提供了访问修饰符public, protected和private,让我们选择派生类中基类成员对外的可访问性。

1
2
3
4
继承的访问修饰符
private 基类成员对外全不可见,即变为private
protected 基类public成员和protected成员变为protected,即对派生类可见
public 基类成员的可访问性没有改变,public仍为public,其他亦然

需要注意的是,成员不可访问不代表成员不存在/未被继承。实际上,派生类继承了基类的所有成员,只是无法直接访问基类的private成员。

现在,我们可以这么表示fruit和apple类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
enum color {
red,
white,
green,
blue,
yellow
};
class fruit {
public:
color c;
int size;
};
class apple : public fruit {
public:
void eat() {
std::cout<<"emmm...apple tastes sweet"<<std::endl;
}
};
int main() {
apple a;
a.c=red; //apple中的c和size成员从基类fruit中继承
a.size=30;
a.eat(); //eat是apple的成员方法,自然可以调用
return 0;
}

如果以后增加了其他水果,也可以通过这种方式添加,从而避免把基类的成员都copy一遍,使代码更加清晰简洁。

多态

多态(polymorphism),顾名思义,对于一个东西有多种不同的状态。

重载

在编译时的多态主要由函数重载(overload)实现。函数重载指同样名称的函数可以存在多个,每个函数接收不同(种类、数量)的参数,编译器在编译时确定调用的具体是哪个函数。

1
2
3
4
5
6
7
8
9
10
11
12
#include<iostream>
void print_num(int a){
std::cout<<a<<std::endl;
}
void print_num(int a, int b){
std::cout<<a<<" "<<b<<std::endl;
}
int main(){
print_num(3); //3
print_num(1,2); //1 2
return 0;
}

虽然这两个函数都叫print_num,但是接收的参数个数不同,因此可以被区分开来。对于对象的方法同理。

虚函数与重写

依然举前面水果的例子,现在有一个人吃早饭,从盒子里随机挑了一个水果吃。假如我的程序要实现这样的逻辑,应该怎么写?

1
2
3
4
5
6
7
??? get_random_fruit(){ //返回值是什么?
//假如有apple类和orange类,我该怎么返回?
}
int main(){
get_random_fruit().eat(); //编译器怎么知道调用的是apple::eat还是orange::eat?
return 0;
}

对于第一个问题,在C++中,一个指向派生类的指针/引用可以被隐式转换为指向基类的指针/引用。即不管具体是apple*还是orange*,都可以隐式变为fruit*类型

1
2
3
4
5
6
7
8
9
10
11
#include<cstdlib>
#include<ctime>
fruit* get_random_fruit(){
srand(time(nullptr));
switch(rand()%2){
case 0:
return new apple();
case 1:
return new orange();
}
}

而对于第二个问题,我们来认真思考一下。

1
2
3
4
5
6
7
8
9
class fruit {};
class apple : public fruit {
public:
void eat();
};
class orange : public fruit {
public:
void eat();
};

要是这么写,对于返回的fruit*类型对应的对象,根本没有eat方法,编译器显然不会接受对它调用eat方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
class fruit {
public:
void eat() {}
};
class apple : public fruit {
public:
void eat() {
std::cout<<"emm...apple tastes sweet"<<std::endl;
}
};
class orange : public fruit {
public:
void eat() {
std::cout<<"ehh...orange tastes sour"<<std::endl;
}
};

要是这么写,fruit对象虽然可以调用eat了,但是编译器不知道函数返回的fruit*指向的究竟是fruit, apple还是orange,所以编译器根据fruit*类型,会调用fruit::eat

对于这样的问题,我们可以用虚函数(virtual function)和重写(override)解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
class fruit {
public:
virtual void eat() {}
};
class apple : public fruit {
public:
void eat() override {
std::cout<<"emm...apple tastes sweet"<<std::endl;
}
};
class orange : public fruit {
public:
void eat() override {
std::cout<<"ehh...orange tastes sour"<<std::endl;
}
};

在基类的方法中声明virtual,意味着这个方法是可以被重写的。而在派生类的同名方法中声明override,意味着重写了基类的虚函数。

现在编译器仍然不知道fruit*指向的是哪类对象,但是这三个对象在初始化时都会携带自己的类型信息,其中包含对应的eat方法所在的内存地址,在运行时直接调用类型信息中的函数指针即可。(关于这一点,我们会在接下来的章节中进一步讲述)

由此可见,在编译时,我们不考虑fruit*具体指向的是什么类型的对象,在运行时我们直接根据类型信息寻找调用的方法的内存地址即可。这体现了运行时多态。

抽象类与接口

对于上面的例子,fruit, apple和orange,有这样一个问题。现实中存在苹果,存在橙子,但不存在一个具体的水果种类叫“水果”。因此,我们不应该有fruit类的实例,而只应有apple和orange类的实例。

在C++中,我们通过将类的成员方法声明为纯虚函数(pure virtual function)来表示一个类为抽象类(abstract class)。我们不能初始化抽象类的实例。

1
2
3
4
5
6
7
8
class fruit{
public:
virtual void eat() = 0; //声明为纯虚函数
};
int main(){
fruit f; //编译时错误
return 0;
}

然而,能吃的不一定是水果,甚至不一定是食物,也可能是药物,或者我觉醒了奇怪的xp之后什么都想吃(?),那么eat方法所在的基类究竟该是什么?我们注意到我们吃的东西都有一个特征“能被吃”,我可以将这个特征抽象为一个接口(interface),将其称为IEatable,凡是继承并实现了这个接口的成员方法的类,都可以调用eat方法。通过接口,我们解耦了相关的逻辑,使得代码更加清晰。

在C++语言中并没有interface这一概念,我们可以通过写抽象类实现接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<iostream>
class IEatable {
public:
virtual void eat() = 0;
};
class ICuttable {
public:
virtual void cut() = 0;
};
class apple : public IEatable, public ICuttable {
public:
void eat() override {
std::cout<<"this apple tastes good"<<std::endl;
}
void cut() override {
std::cout<<"this apple is cut into pieces!"<<std::endl;
}
};

在这里,我们的apple既可以吃,也可以切。代码中,apple同时继承了多个基类,这被称为多继承。

C++中的对象

构造函数

当新建一个类的实例时,对象要被初始化。初始化对应的函数就叫做构造函数(constructor)。

在构造函数中,对象常常通过接收到的参数设置自身的成员,完成初始化操作;或是为自己的成员申请内存。声明构造函数与声明方法类似,只不过构造函数没有且不标注返回值,名称与类名相同,且不能为虚函数。与方法类似,构造函数受访问修饰符修饰,且可以被重载。

1
2
3
4
5
6
7
8
class apple {
public:
apple(int size) {
this->size=size;
}
private:
int size;
};

需要注意的是,一个类默认有自己的无参构造函数。如果我们不希望它存在,可以通过explicit关键字声明我们重载的构造函数,从而屏蔽无参构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class apple1 {
public:
apple1(int size) {
this->size=size;
}
private:
int size;
};
class apple2 {
public:
explicit apple2(int size) {
this->size=size;
}
private:
int size;
};
int main(){
apple2 foo1(3); //在变量后加括号表示调用它的构造函数。这条语句是合法的
apple1 foo2; //不加括号也会隐式调用无参构造函数。这条语句是合法的
apple2 foo3; //编译错误,因为apple2类不存在无参构造函数
return 0;
}

在构造函数中,我们常常需要将传入的参数赋值给对象成员,C++为我们提供了这样的简便写法

1
2
3
4
5
6
class apple {
public:
explicit apple(int s) : size(s) {} //大括号中为构造函数体,此处我们不需要进行其他操作所以为空
private:
int size;
};

另外,我们也可以在类的声明中为其成员赋初值。这样在初始化类的实例,调用构造函数时,成员会首先被赋值。

1
2
3
4
5
class book{
public:
std::string title = "default_title";
std::string content;
};

在book类被初始化时,title成员被赋值”default_title”,而content成员则是调用std::string的无参构造函数后得到的std::string对象。注意此处没有显式为content赋值,但仍会调用std::string的构造函数,这与上面我们声明对象变量时(如apple foo;)相似。

析构函数

当一个对象离开它的作用域时,或手动释放对象时,对象要被销毁。销毁对应的函数就是析构函数(destructor)。析构函数不接受参数,没有返回值,名称为~类名。在析构函数中,我们应该释放对象之前显式申请的内存资源(例如malloc得到的内存),执行销毁时的特定逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class book {
public:
book() {
title=malloc(256);
content=malloc(256);
}
~book() {
free(title);
free(content);
}
private:
char* title = nullptr;
char* content = nullptr;
};

在对象析构时,如果对象成员也是对象(不包括对象指针或引用),那么成员对象也会被析构。

对于有继承关系的类,因为多态,在delete对象指针时,对象的实际类型可能不是指针对应的类型

1
2
fruit* f = new apple();
delete f; //f实际上指向apple对象

而基类和子类的析构逻辑很可能是不一样的,因此对于有继承关系的类,我们应该使用虚析构函数。

1
2
3
4
5
6
7
8
9
class fruit {
public:
fruit() = default; //赋值为default即使用默认的函数
virtual ~fruit() = default;
};
class apple : public fruit {
public:
~fruit() override = default;
};

此时再回想构造函数,因为在构造对象时,对象的类型一定是确定的,不存在多态,所以构造函数一定不是虚函数。

拷贝构造函数

与构造函数类似,但只接受一个当前类的常量左值引用的构造函数称为拷贝构造函数(copy constructor)。

1
2
3
4
class foo {
public:
foo(const foo& f) = default; //拷贝构造函数
};

除了像构造函数一样使用外,在对象发生拷贝时(例如函数传参将对象值传递,或是将一个对象赋值给另一个同类型对象),拷贝构造函数会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
class foo {
public:
foo(const foo& f) = default; //拷贝构造函数
};
void bar(foo f) {
// f是传递过来的参数经拷贝得到的
}
int main(){
foo f1;
foo f2=f1; //拷贝
foo f3(f1); //像调用构造函数一样调用拷贝构造函数
bar(f1); //值传递时拷贝
}

移动语义与移动构造函数

在实践中,我们常常遇到一些“所有权转移”的问题。假如我实现了一个字符串类mystring,现在要将两个mystring类型变量的值互换:

1
2
3
4
mystring a,b;
mystring tmp=a;
a=b;
b=tmp;

在这样的过程中进行了额外的拷贝(tmp拷贝a,a拷贝b,b拷贝tmp),在一些情况下会造成比较大的性能损失。我们原本要实现的并不是拷贝,而只是移动。因此引入了移动语义std::move(位于头文件utility)

std::move将变量的类型强制转换为右值引用,此时再进行赋值会调用对象的移动构造函数:

1
2
3
4
class mystring {
public:
mystring(mystring&& s) = default; //移动构造函数
};

此时我们交换两变量就可以写成

1
2
3
4
mystring a,b;
mystring tmp=std::move(a);
a=std::move(b);
b=std::move(tmp);

经过move后,原先的变量存储的实例不应再被使用。此时再访问a的成员属于未定义行为。

需要注意的是,std::move并不实际上移动对象内部的值,而是交由移动构造函数处理。在声明了拷贝构造函数而未声明移动构造函数的情况下,会默认调用拷贝构造函数。移动的具体行为是由移动构造函数定义的,并不一定会带来性能上的提升,而是取决于具体实现。

我们可以通过以下的例子感受std::string的移动

1
2
3
4
5
6
7
8
9
10
11
#include<string>
#include<cstdio>
using namespace std;
int main(){
string a("gfjerajgoiraheogehwofiewjiwihgerihgeioafheoifjowejgoidoqwi"); //因为短字符串优化机制会影响字符串存储的位置,此处应为较长的字符串
printf("%p\n",a.c_str());
string b=a;
string c=std::move(a);
printf("%p\n%p\n",b.c_str(),c.c_str());
return 0;
}

我们注意到a存储字符串的位置,与a移动到c后存储字符串的位置相同,体现了移动。而b是由a拷贝而得,在堆上申请了内存,因此在仅需要移动的情况下使用拷贝会带来性能损失。

友元类与友元函数

运算符重载

强制类型转换

在C++中,我们仍然可以使用C风格的强制类型转换

1
char* ptr = (char*)malloc(256);

然而,在一些场景下,这样“简单直观”的类型转换会带来难以察觉的问题。因此,C++引入了4种强制类型转换

const_cast

const_cast用于将常量引用/指针转为非常量引用/指针,从而使其可写。注意这只是进行了类型转换,如果指针指向的位置本来就不可写,如果进行转换,即便通过了编译,在运行时也会出现问题。

1
2
3
4
5
6
7
8
int main(){
const char* p1 = "hello world!";
char arr[15]="hello world!";
const char* p2 = arr;
const_cast<char*>(p1)[0]='a'; //p1对应的字符串存储在常量区,不可被修改。转换不合法
const_cast<char*>(p2)[0]='a'; //p2指向可写的内存,可以被修改,因此转换合法
return 0;
}

static_cast

static_cast用于进行一些数值类型上的转换(如有符号/无符号 长/短 整型/浮点数),和类的强制转换(要求重载强制转换运算符)。

dynamic_cast

dynamic_cast用于有继承关系的类之间的转换,转换发生在运行时,程序会根据对象的类型信息进行判断。若对象指针之间的转换不成功,会得到空指针;若对象引用之间的转换不成功,会抛出std::bad_cast异常。

reinterpret_cast

多继承与菱形继承问题

Itanium ABI中的对象内存模型

常用STL

std::string

std::vector

智能指针