C++面向对象编程基础知识

参考南软2024秋C++高级程序设计课件 CPP-2-1

为什么使用面向对象

不使用面向对象的隐患

  • 安全隐患:结构体的访问控制权是public的,这意味着其中所有变量外部可以自由访问,而某些状态应当只通过相关的函数调用更新
  • 不符合数据类型定义:public权限带来的衍生问题,对外暴露出来的数据结构(实现)可能并不满足数据类型的定义/要求,如栈用数组实现,但数组对外暴露了,可在任意位置更新、操作而非栈顶、压栈、出栈。

使用面向对象

  • 对数据结构进行封装,各司其职,利于对象之间的协作和任务分解
  • 信息隐藏,对外暴露必要的接口和功能,而非实现

面向对象的视角

结构化编程

程序 = 算法 + 数据结构

面向对象编程

程序 = \sum对象

对象 = 数据 + 行为(体现职责)

信息传递:函数调用

用类来管理

面向对象体系下的项目开发流程

需求,架构,构建模式,代码,测试用例,项目组织

评价标准:开发效率

封装

  • 成员变量
  • 成员函数

默认提供的函数

编译器会为类提供一些默认函数,只要你不显式地声明相关的函数

class Empty {
// Empty();
// ~Empty();
// Empty(const Empty&);
// Empty& operator=(const Empty&);
// Empty* operator&();
// const Empty* operator&() const;
};

共6个(除去常说的构造、析构、拷贝、赋值,还要注意会有两个版本的取地址操作符默认提供)

还要注意的是编译器只有在你用到相关函数的时候才会生成对应的默认版本(前提是没有显式声明),比如如果你不使用Empty去构造一个类就不会生成默认构造函数,其余函数同理

下面以gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04),Target: x86_64-linux-gnug++编译器为例,编译指令为

$ g++ tmp.cc -S -o tmp.s -O0

比如不使用Empty构造类,则编译器没有生成对应的默认构造函数

class Empty{
int a = 1;
};

int main() {
return 0;
}
        .file   "tmp.cc"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:

反之生成了

class Empty{
int a = 1;
};

int main() {
Empty* e = new Empty();
return 0;
}
.file   "tmp.cc"
.text
.section .text._ZN5EmptyC2Ev,"axG",@progbits,_ZN5EmptyC5Ev,comdat
.align 2
.weak _ZN5EmptyC2Ev
.type _ZN5EmptyC2Ev, @function
_ZN5EmptyC2Ev:
.LFB2:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl $1, (%rax)
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE2:
.size _ZN5EmptyC2Ev, .-_ZN5EmptyC2Ev
.weak _ZN5EmptyC1Ev
.set _ZN5EmptyC1Ev,_ZN5EmptyC2Ev
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
pushq %rbx
subq $24, %rsp
.cfi_offset 3, -24
movl $4, %edi
call _Znwm@PLT
movq %rax, %rbx
movl $0, (%rbx)
movq %rbx, %rdi
call _ZN5EmptyC1Ev
movq %rbx, -24(%rbp)
movl $0, %eax
movq -8(%rbp), %rbx
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:

模块

foo.cppm

export module M; // 导出为模块M
import K; // 同时依赖模块K

export int func(int x) {
return 2 + square(x);
}

foo2.cppm

export module K; // 导出为模块K
// 不依赖任何其他模块

export int square(int x) {
return x * x;
}

bar.cpp

import M; // 依赖模块M
import K; // 依赖模块K

int main() {
square(2);
func(5);
return 0;
}

clang中的模块单元在 .cppm

编译后输出

  • 可导入的pcm
  • 可链接的对象文件

根据上述依赖关系,编译顺序为K => M => …

构造函数

java一致,默认构造函数无参,支持重载

  • 当类中不实现任何构造函数,编译器会提供默认构造函数(各种)
  • 一旦定义任何一种构造函数,编译器不再提供默认(五三原则的背景),但可以手动=default

构造函数既可以是public的也可以是private的;private的构造函数旨在类自己接管对象创建(如单例模式)

五三原则

事实上C++11为三原则(Rules of Three),C++17引入了移动的语义,因此扩展到了五原则(Rules of Five)

规定:(事实上是一种好的编程实践)若在类定义时重载了拷贝构造函数、拷贝赋值运算符、析构函数、移动构造函数、移动赋值运算符之一或之几,也要主动重载剩余的,即牵一发而动全身

调用

创建对象时自动调用

示例

class Obj {
public:
Obj();
Obj(int i);
Obj(char* p);
};

int main() {
Obj o1 = Obj(1); // <=> Obj o1(1); <=> Obj o1 = 1;
Obj o2 = Obj(); // 不能写成 Obj o2();
Obj o3 = Obj("abcd"); // <=> Obj o3("abcd"); <=> Obj o3 = "abcd";
Obj a[4]; // 从a[0] ~ a[3], 自动调用Obj()
Obj b[5] = {Obj(), Obj(1), Obj("abcd"), 114, "514"};
}

:形如Obj o1 = 1; 这种写法仅限于构造函数没有被 explicit关键字修饰,因为这种写法实际上发生了隐式类型转换,自动触发了隐式构造函数调用,从整型1转成了对象。

成员初始化表

  • 对构造函数的补充
  • 会先于构造函数体被执行
  • 建议按类成员的声明顺序进行初始化,可以减轻编译器的负担(在较新版的CLion里不按顺序会警告)

示例

class A {
int x;
const int y;
int& z;
public:
A(): x(114), y(514), z(x) { // <- 成员初始化表
x = 0;
}
};

如果类中持有其他对象,则在构造函数执行时会逐级调用成员对象的构造函数(若在构造函数中初始化成员对象)

当成员变量不多时,建议使用成员初始化表代替赋值操作

使用成员初始化表的缘由
  • const成员变量和引用成员变量必须在成员初始化表中初始化,而不能在构造函数体中进行赋值
  • 派生类初始化基类也必须在成员初始化表中完成(:后面,{前面)
  • 效率高
  • 例外:成员变量过多,再使用成员初始化表会降低可读性和可维护性

析构函数

析构函数会在对象消亡时自动调用,一般需要程序员在其中主动释放对象持有的非栈内存资源(如指针指向的堆内存

和构造函数一样可以是public也可以是private

私有析构函数
class A {
public:
A();
void destroy() {delete this;}
private:
~A();
};

当析构函数被private修饰,则不能通过形如A a;的形式在栈上创建对象(私有构造函数应该也是同理),因为此时的析构函数为私有,外部无法访问到,编译器无法实现在对象生命周期结束时自动释放对象,因此会触发编译错误。因此只能通过A* a = new A;形式创建。

同理,直接delete a也是会触发编译错误的,同样是因为析构函数为私有,外部作用域访问非法。因此类内部必须对外实现一个public的接口,并在接口实现中主动调用delete(类内部作用域可访问私有成员),防止内存泄漏。

一个更好的实践:

class A {
public:
// ...
static void free(A* p);
private:
~A();
};

static void A::free(A* p) {
delete p;
}

int main() {
A* a = new A();
// ...
A::free(a);
// ...
}
GC vs RAII

众所周知Java使用一种叫做垃圾回收(Garbage Collection)的机制管理和释放资源,但这也导致了Java运行的效率瓶颈;而C++采用一种叫做RAII(Resource Acquisition Is Initialization)的机制让程序员自行实现资源的分配(构造函数中)与释放(析构函数中),再由编译器管理的构造与析构函数集中处理。

拷贝构造函数

创建对象时,传入同类对象对其进行初始化,往往用来实现对象深拷贝操作。

也是自动调用的(创建对象时自动匹配重载版本)

浅拷贝

不论变量的类型,只是对变量值进行一份拷贝,这对于值类型无可厚非,但对于指针变量,浅拷贝只会复制指针的值(即地址),而非其中的内容,这将导致多个指针指向同一块内存,一旦通过其中某个指针释放了内存(本意可能只是释放特定的指针),所有剩余的指针都将悬挂,造成悬挂指针隐患

深拷贝

对于值类型变量仍是拷贝一份值存储,而对于指针类型变量,则会额外开辟一块新内存,并将旧指针指向地址中的数据拷贝到新内存中。

默认的拷贝构造函数
  • 默认是浅拷贝的
  • 逐个成员初始化
  • 若存在成员对象,则递归地执行上述步骤,即对每个成员对象也调用其拷贝构造函数
什么时候需要实现拷贝构造函数

深拷贝防止浅拷贝引入的悬挂指针风险。

移动构造函数(C++17)

右值的概念

声明参数时使用&&告知编译器这里要传一个右值参数,与左值不同,右值只有本身的值,不能被赋值。

示例

string::string(string &&s): p(s.p) {
s.p = nullptr;
}

*注:*上例中的s虽然在传参时是右值,但是传完参之后的s是一个左值,存储的是一个右值,如果需要把它作为右值传给其他需要右值的函数,必须使用std::move()获取右值。

void process(string &&s) {
//...
}

string::string(string &&s) {
// process(s); 会触发编译错误
process(std::move(s));
}
为什么需要移动构造函数?
  • 性能提升:可以高效的转移资源,不同于深拷贝的逐字节复制,移动操作直接转移资源的所有权,以O(1)O(1)时间复杂度高效完成
  • 内存管理:转移资源所有权避免了重复开辟新内存,避免了不必要的空间开销和内存浪费,简化资源的管理(不需要管理多份相同资源)
  • 支持标准库容器的优化,比如vector的扩容
  • 返回值优化——避免不必要的内存分配,当在函数中返回一个局部对象时,C++11引入的返回值优化(RVO)和移动语义(通过移动构造函数和移动赋值运算符)可以避免不必要的内存分配。

返回值优化示例:

MyString createString() {
MyString str("Hello, world!");
return str; // 返回时可能触发移动构造函数, 避免调用拷贝构造函数创建新的返回值对象,造成对str的拷贝和内存的重新分配(浪费)
}
  • 简化代码

动态内存

动态内存分配在堆上

C:

  • malloc
  • free

C++:

  • new
  • delete

动态对象

创建

在堆上通过new创建,并通过delete主动释放其内存。其中new绑定constructor,delete绑定destructor。

示例

class A {
public:
A();
A(int i);
};

int main() {
A* p, *q;
p = new A; // <=> p = new A();
q = new A(114);
// ...
delete p; // 调用p所指向对象的析构函数,释放对象空间
delete q;
// ...
}

C++支持对基础类型也是用new(完全替代C的malloc)来进行动态内存分配

int* ptr = (int*) malloc(sizeof(int));
int* ptr = new int; // 两种写法对于基本类型是等价的

*注:*无论存储的数据占多大的空间,指针本身的大小都是一样的(4B)

C风格的malloc不会调用构造函数new会),只是分配一块指定对象类型的空间,并不会进行初始化;同理free也不会调用析构函数delete会),只是释放指定对象被分配的空间,不会释放其进一步占用的资源。

删除

delete ptr;

需要注意的是,delete只是释放ptr指向的内存被分配出去的空间,即这块空间不再是被分配的状态,不再属于ptr(不再是合法空间),而ptr指针本身的值没有发生改变,即仍指向这块不再属于自己的空间,成为悬挂指针。因此,一个安全的编程习惯是:delete掉一个对象后把对应的指针置为nullptr

delete ptr1;
ptr1 = nullptr; // 释放后置为nullptr
delete ptr2;
ptr2 = nullptr;
动态对象数组

创建:A *p = new A[100];

删除:delete[] p; // []不可省

注意以下几点:

  • p是一个指针(数组的首地址),p指向的数组空间的每个元素是具体的对象A, 不是指针
  • 这种写法无法显式初始化,相应的类A必须提供默认(无参)构造函数,如果没有会触发编译错误
  • delete中的[]不能省
delete时[]不能省的原因

delete期望释放分配出去的所有空间,但指针本身不记录数组的大小信息,编译器仅从deleteptr无法知道应该释放多少内存。而数组空间在分配时会在指针指向的首地址前预留4B的内容,其中存了数组的大小,而[]会告诉编译器去找指针前4字节的内容,确认要释放多少内存。

二维数组分配时先分配行,再给每一行分配列;释放时先释放各行的每一列,再释放每一行(和分配反过来)

const成员

const成员属性的初始化在构造函数的成员初始化表中进行

注意const修饰的位置:

  • const A*是指针指向空间的值不能变
  • A* const是指针本身的值不改变(常量指针)
const成员函数

函数在声明时被const修饰,代表该函数中不会改变任何变量的值,只读

class A {
int x, y;
public:
A(int a, int b): x(a), y(b) {}
void f();
void show() const; // show是const成员函数,里面不可修改成员
};

void A::f() {
x = 1;
y = 2;
}

void A::show() const {
std::cout << x << y << endl;
}

对于上述代码编译器如何检查被const修饰的成员函数的定义是否合法呢?编译器会对const修饰的函数中所有变量都带上const,这样若函数定义中尝试写一个变量,就会触发编译错误

继续上述代码,若定义了一个常量对象a

const A a(0, 0); // a是常量对象,其中的所有成员变量都会被编译器自动带上const

若对象在声明时被const修饰,则这个对象中所有成员变量都会自动加上const(编译器来做,同时做后续对const的检查),因此a只能调用类中的const成员函数,因为编译器无法确定非const函数是否会”写“成员变量,但const成员函数一定不会,因此编译器只允许调用const函数确保const的变量不被写,而非const的调用会触发编译错误

// a.f(); 编译错误
a.show(); // 可以通过编译

静态成员

既可以通过对象使用,也可以直接通过类名作用域来使用

A a;
a.f();
A::f();

C++支持观点:类也是对象

静态成员变量

所有类的示例共享同一个静态成员变量。

为什么要引入静态成员——同一个类的不同对象如何共享变量?

  • 如果使用全局变量,缺乏数据保护
  • 且会造成名污染
class A {
int x, y;
static int shared; // 内部声明
// ...
};

int A::shared = 0; // 外部定义来初始化
  • 类对象共享
  • 唯一拷贝
  • 遵循类访问控制(public,protected,private)

需要注意的是,静态成员的定义必须在外部不能在内部(const除外),因为静态变量在编译器眼中类似全局常量,全局常量不能重定义(内部定义静态变量会有重定义问题,比如反复include多次),因此静态成员变量在类内声明,类外定义;但如果是static const,还是要在内部定义(const要求)。

静态成员函数

static修饰函数

  • 只能存取静态成员变量,调用静态成员函数
  • 遵循类访问控制

友元

friend关键字,使外在友元的定义中也能访问类私有成员。不使用友元就只能通过public访问成员或对private成员提供getter/setter,降低了对private成员的访问效率。

分类
  • 友元函数
  • 友元类
  • 友元类成员函数

示例:

void func();
class B; // 前向声明,在此处只声明一个类B,告诉编译器有这么一个类,但是不定义
class C {
// ...
void f();
};
class A {
//...
friend void func(); // 友元函数
friend class B; // 友元类
friend void C::f(); // 友元类成员函数
};

对上例的解释:

  1. B的前向声明可以省略,因为B的作用域是全局,此处的friend可帮助在全局声明
  2. friend类似extern(但并不是),因此func可以不必在之前先声明(类似全局函数的效果,friend帮助其将作用域扩展到全局),因为friend void func();相当于声明了,可以在之后定义
  3. C::f()必须先在C中声明,不然编译器找不到C::f的声明会编译错误(因为C::f不是全局函数而是类的某个函数,friend做不到像extern那样扩展全局函数的声明范围)
  4. funcBC::f均能访问A的私有成员

友元不具有传递性,即A是B的友元,B是C的友元,但A不是C的友元

另一个示例:

class Vector; // 此处不加前向声明会触发编译错误,因为下面的代码存在循环依赖,谁在前面顶哟都会缺少对方的声明,因此前向声明打破循环依赖

class Matrix {
// ...
friend void multiply(Matrix& m, Vector& v, Vector& r); // 如果这里不用引用Vector&而是直接用具体的对象类型Vector,即使有前向声明也会编译错误,因为前向声明只是声明了Vector但没有定义,编译器不知道Vector多大,也就不知道要在编译时分配多大的空间(编译是静态的),自然会编译错误;而引用的大小固定,所以不会出错。
};

class Vector {
// ...
friend void multiply(Matrix& m, Vector& v, Vector& r);
};

(•‿•)