
oop复习
任课老师:翁恺
知识点回顾
end()
返回的是容器最后一个位置的下一个位置的迭代器string::npos
是size_t
类型的最大值;to_string
将数字转换成字符串:1
2int num = 123;
string str = to_string(num);stoi
将字符串转换成整数参数的默认值只能从右到左给出;默认值只能出现在函数的原型或者将定义和声明放在一起,而不能在分离的定义中声明
列表初始化是按成员的声明顺序执行的,和成员在列表中的顺序无关。
实验操作
截取字符串
用邮箱地址的截取,说明 rfind
与 str
等方法的使用:
1 | string email = "user.name@example.com"; |
Stream
文件流
- 一个简单的输入/输出:流
1 |
|
getline()
函数可以显式指定终止符(第三个参数),默认为\n
也就是换行符。并且会将终止符丢弃
位或运算符指定模式
可以使用 |
来指定多个模式:
1 | // 组合使用打开模式 |
字符串流
字符串流的作用原理:
- 作用: 将字符串转换成一个类似于输入流的对象;
- 内部维护了一个字符串和一个位置指针;
- 每次读取时, 位置指针向后移动, 且自动跳过空白字符.
stringstream
表示双向字符串流
字符串分词
我们可以将字符串转换成字符串流,来实现字符串的分词:
1 |
|
按照空白字符i.e. 空格、
\t
,\n
来分词
字符串的拼接
1 |
|
Output:
1 | Name: Alice, Age: 25 |
通过
.str()
方法可以对象转换为字符串类型, 从而格式化输出.
.str("")
方法可以清空字符串流:
STL
for-each
以更简单的方式遍历容器内元素:
1 | std::map<std::string, double> price; |
map
lower_bound
使用~函数可以查找不小于某个值的第一个键:
1 | std::map<long long, int> tags{{10, 1}, {100, 2}, {1000, 3}, {10000, 4}, {10000000000, 10}}; |
class
include机制
#include
语句的作用是将某个文件插入到语句所在位置。根据搜索的顺序,可以划分不同的用法。
#include "xx.h"
:先搜索当前文件夹,再搜索系统库#include <xx.h>
:搜索系统库#include <xx>
:搜索系统库
注意权限的管理是针对于类的,同一类的不同对象可以在其成员函数内任意访问别的成员
友元不具有传递性:
friend class
+一个类名,可以指定友元类friend
+一个函数的声明,指定友元函数
生命周期:
- main外的类的对象(i.e. 全局作用域),其构造函数调用的时间早于
main
函数;其析构函数的调用也在main
函数返回之后
静态
静态指的是:
- 空间的静态
- 受限的访问
- 静态局部变量在第一次遇到的时候初始化
- 静态成员变量在
.h
文件中的声明有static
标签,但是在.cpp
中不应该有~标签;否则无法被其他文件中使用。 静态成员函数同理
两种访问静态内容的方式:
1 | <class name>::<static member> |
引用
一般的字面量都是右值,但是字符串是例外,因为字符串实际上存储在静态内存区
一般来说,左值引用不能绑定右值,但是常量左值引用可以绑定右值,因为常量的特性确定了不会对右值进行修改
但是如果同时存在右值引用,右值作为参数时还是会优先重载右值引用的版本
规范:
不允许定义引用的引用;
不允许定义引用的数组;
- 引用不是单独存在的对象,无法按照数组存储
不允许定义指向引用的指针
- 指针必须指向对象,而引用不是对象
常量
使用 const
标记声明为常量
常量可以直接让编译器尝试替换:
1 | const int bufsize = 1 << 10; |
但是需要特别注意的是:对象的常量不是编译器常量! i.e.
1 | class Array{ |
可以使用枚举或者
static
来解决上述的问题:
1
2 enum {size = 10};
static const int size = 10;
常量和指针;
const *p
表示不能通过指针改变指向的对象内容;* const p
表示不能改变指针的指向位置
不需要关注类型与
*
的位置关系
字符指针和字符数组;
1 | char *sp = "Hello World!"; // 字符指针可以移动,不能修改 |
实际上,
char *sp
就是const char *sp
,所以不能改变字符串的值,但是可以改变sp的指向而字符串数组的数组名是栈中的固定地址,无法移动,但是可以修改
注意区分常量函数和返回值的常量:
1 | int getName(int id) const; // 常量函数,无法改变成员变量,常量对象只能调用自己的常量成员函数(与静态成员函数) |
delete
注意 []
搭配的使用:
1 | int *p = new int[10]; |
Inside class
代理构造
可以在一个构造函数中调用另一个构造函数,减少代码的重复:
1 | class sorted{ |
内联函数
通过 inline
关键字,建议编译器将函数调用处替换为函数体代码本身 ,从而避免函数调用开销。
是否作为内联函数,实际上由编译器所决定
内联函数必须提供完整的函数定义
i.e. 内联函数的声明必需伴随实现(在同一个头文件中即可,二者可以分离)
组合与继承
组合
组合的对象分为完全包含和引用包含,什么时候使用引用包含呢?
- 逻辑上子对象应该在对象的外部;
- 子对象的大小不确定
- 子对象的空间应该在运行时被分配或者链接
命名空间
1 | namespace sp1{ |
- 可以为命名空间声明别名;
- 命名空间的末尾没有分号
;
using
使用 using
可以在当前的作用域引入其他的命名空间的成员、函数:
using <namespace>::<member>
引入部分的成员using namespace <namespace>
引入该命名空间的全部成员
e.g.
1 | using std::cin; // 只引入 cin |
如果引入同名的对象或者函数,将会导致编译器链接失败
继承
父类的析构函数更晚调用
父类的私有成员变量在子类的对象中依旧存在,但是不可直接访问(只能通过父类方法来间接访问)
父类的受保护成员可以被子类访问,但是无法被外界访问
非公开的继承
1 | class B: protected A{ |
如果定义
protected
继承,只有子类及其派生类可以调用父类方法,外部是不可以的。如果定义
private
继承,只有子类本身可以调用父类方法。
静态成员的继承
父类的静态成员不会在子类中具有自己的副本,子类和父类共享一个静态成员!
以下介绍using相关的几个问题:
name hiding
如果子类重载了父类的函数,父类的同名函数将会失效,无法直接调用,需要使用 using
来声明:
e.g.
1 | class Base{ |
默认参数的重载传递
在cpp中, 默认参数值绑定在函数声明的作用域上, 而不是函数本身! 这是为了避免 多重继承时参数值产生冲突或二义性 。
默认参数是静态绑定(编译期行为),它必须清楚地知道取哪个作用域的值.
e.g.
1 | class A { |
子类重写父类函数
如果子类直接重写了父类的函数,但是父类中的同名函数本身具有重载的版本,那么也需要使用 using
来声明:
1 |
|
多态
- 我们应当将所有类的析构函数都设置为虚析构函数,因为每个类都有成为父类的可能
1 | class Base { |
子类和父类的析构函数先后调用
vptr
在构造的时候确定,虚继承的子类在调用父类的构造函数时,默认调用父类的成员函数1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class A {
public:
A() {
f();
}
virtual void f() {
cout << "A::f()";
}
};
class B : public A {
public:
B() {
f();
}
void f() {
cout << "B::f()";
}
};
B temp;
虚函数的规范
注意 virtual
与 override
的搭配:
1 | class Animal { |
override是为了让编译器检查该函数在父类中是虚函数,但不是必须的
- vtable是类级别的, 所有该类的对象共享一个vtable;
- vptr是对象级别的, 隐含于各个对象当中.并且在内存的开头
抽象类
抽象类:至少包含一个纯虚函数的类是~
1 | class Shape { |
纯虚函数类似于协议,要求子类必须实现
只有完成了所有纯虚函数定义的、抽象类的子类,才能够实例化
接口类 Interface Class:
- 比抽象类更加抽象)
- 只定义接口, 不提供实现的抽象类
- 所有的成员函数都是纯虚函数;
- 一般不包含任何数据成员.
菱形继承与虚继承
在继承时添加 virtual
关键字实现,子类中不存在父类的对象,而是保有父类的指针。
1 | class A |
上述的虚继承确保了B,C只会拥有A的一份value,从而避免了从D的对象访问 value
时存在的二义性
并非所有的菱形继承都会因为二义性的访问而导致访问的问题!
TODO:检查什么时候没有二义性?
由于虚继承带来的是“共享”的基类对象,所以:
- 虚基类的构造 必须由最底层派生类负责
- 派生类的构造函数中要显式初始化虚基类
Copy & Move
- 拷贝构造函数的签名:
T::T(const T&)
- 默认的拷贝构造函数的指针类型成员是直接赋值的,也就是共享同一地址
- 因此我们需要显式定义类的拷贝构造函数,避免依赖默认的~
- 默认拷贝构造中,成员对象也会调用自己的拷贝构造函数
拷贝构造函数的调用时机
- 按值传递参数时:当对象作为参数按值传递给函数时
1
void func(MyClass obj); // 调用时会触发拷贝构造
- 对象初始化时:
1
2
3MyClass a;
MyClass b = a; // 初始化,调用拷贝构造函数
MyClass c(a); // 初始化,调用拷贝构造函数 - 函数返回对象时:
1
2
3
4MyClass func() {
MyClass obj;
return obj; // 可能触发拷贝构造(取决于编译器优化)
}
一个简单的例子:
1 | StringHolder(const StringHolder &other) |
右值引用
两种可以同时输入左值和右值引用作为参数的方法:
重载
1
2
3
4
5
6
7
8// 重载函数,分别处理左值和右值
void process(int& x) {
std::cout << "重载函数 - 处理左值: " << x << std::endl;
}
void process(int&& x) {
std::cout << "重载函数 - 处理右值: " << x << std::endl;
}const int& x
1
2
3
4// 接受const左值引用的函数(可以接受左值和右值)
void processAny(const int& x) {
std::cout << "处理任意值: " << x << std::endl;
}
移动构造函数
- 签名:
T::T(T&& other)
, 也就是将右值引用作为参数的构造函数 - 用于”窃取”即将销毁的对象的资源,避免不必要的深拷贝
- 通常将源对象的指针成员置为
nullptr
,防止资源被错误释放. 其他成员设置为零值
一个简单的例子:
1 | class DynamicArray |
noexcept
标记表示不会抛出异常;标准库容器在进行元素移动时会优先选择不会抛异常的移动构造函数,如果不存在~会退而求其次选择拷贝构造函数(更慢)。
move
如果对象不是右值引用,我们可以使用 std::move
来显式调用移动构造函数:
1 | vector<int> v1{1, 2, 3, 4}; |
初始化的方式
C++11提供了多种初始化对象的方式:
小括号初始化:
MyClass obj(arg1, arg2);
等号初始化:
MyClass obj = value;
大括号初始化(统一初始化):
MyClass obj{arg1, arg2};
列表初始化:
1
2int arr[] = {1, 2, 3};
MyClass* ptr = new MyClass{arg1, arg2};
基本类型的初始化
1 | // 基本类型的初始化方式 |
对象的初始化
1 | // 对象的初始化方式 |
动态分配对象的初始化
1
2
3// 动态分配对象的初始化方式
Person *pp1 = new Person("动态张三", 25); // 传统new
Person *pp2 = new Person{"动态李四", 30}; // 大括号初始化(C++11)
统一初始化
对于简单的类或者容器内部的类,我们可以不写构造函数,而是用花括号进行 统一初始化 。
如果类没有构造函数,参数应按照成员的声明顺序给出;如果有,参数应按照构造函数的参数顺序给出。
1 | class Test{ |
操作符重载
运算符重载本质上是一个以 operator
关键字为前缀,后跟运算符的特殊函数
- 赋值运算符首先要检查是否为自赋值
- 必须在类或者枚举类上定义
- 类内成员函数的重载将第一个参数作为隐式的
this
传递,操作符左端的类型决定了使用的操作符的类型3+a1
非法 ifa1
无法转换为int
成员函数与自由函数
=, (), [], ->, ->*
必须是成员函数- 单目运算符应该声明为成员;
- 二目运算符应该声明为自由函数
无法重载的运算符
1 | . .* :: ?: |
参数传递与返回类型
+ - * / % ^ & | ~
返回新的对象const Tp operator X(const Tp & l, const Tp & r);
- 布尔运算,返回
bool
类型 [ ]
返回可以修改的引用,类似于数组的赋值Tp & operator X(int index);
= += *= /= <<= >>=
返回可以修改的引用,确保链式操作Tp & operator X(const Tp &l, const Tp &r);
前缀自增和后缀自增需要区分,在后缀自增的参数列表中添加 int
即可
1 | // 前缀自增返回引用 |
比较运算符
考虑实现基础的 ==
与 <
,其余的比较重载在上述的基础上通过 !
运算实现,便于直接迁移
流运算
1 | istream& operator>>(istream& is, _Tp& obj){ |
- 输出流运算符的第一个参数不能是
const
,因为输出会修改流 - 输入流运算符的第二个参数不能是
const
,因为需要修改对象 - 通常需要声明为友元以访问私有成员
赋值运算符
1 | _Tp& _Tp::operator=(const _Tp& rhs){ |
首先检查是否为自赋值
如果不希望进行赋值运算,将上述的 =
重载声明为 private
,并且无需实现。
这是因为
a = b; // 实际等价于:a.operator=(b)
, 发起调用的是当前代码片段所在的作用域,如果不是类内函数,那么就是非法的
隐式转换
1. 构造函数转换:从其他类型到当前类型
1 | class PathName { |
2.转换运算符:从当前类型到其他类型
operator <typename> {}
关键字
1 | class Rational { |
可以使用 explicit
关键字要求显式转换:
1 | explicit operator double() const; |
显式转换
- 构造函数的显式转换
1 | // 如果想要强制显式转换,应该这样声明: |
- 类型转换运算符的显式转换
1 | explicit operator double() const; |
转换优先级
如果同时存在两种方向的转换,将优先采取构造函数的转换方式
可以通过声明其中的一种情况必须显式调用来并存;比如必须显式调用构造函数的转换,那么下面的情况将会调用构造函数将A转换到B的对象:
1
2
3
4
5
6
7
8
9
10
11 class B{
public:
B();
explicit B(A); // 从A到B的显式构造函数
};
void functionTakingB(B thing){
cout << "OK" << std::endl;
}
functionTakingB(static_cast<B>(a));
转换运算符
C++ 中有四个转换运算符 Cast Operator:
static_cast
:- 基本类型的转换
- 子类向父类的指针/引用的转换
void
与其他类型指针的转换
dynamic_cast
:down-cast,安全- 父类向子类的指针/引用的转换,不一定总是安全(要求原本指向的对象就是子类对象)
- 要求基类中至少存在一个虚函数(因此具有
vptr
,从而可以通过不同类的vptr
进行类的区分) Base* basePtr = new Derived(); Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); if(derivedPtr){ ... }
> 但是更加常见的是将非const类型的属性修改为 `const`;原本是常量类型的属性可能被编译器存储在只读内存区域,如果编译器没有在 `const_cast`的转换中进行优化,可能导致运行问题? >1
2
3
4
5
6
7* `const_cast`:修改 `const` 属性
* ```cpp
const int a = 10;
int *b = const_cast<int*>(&a);
*b = 20; // 转换之后可以修改value
reinterpret_cast
:忽略类型检查,强制转换,低安全性
Template
一个模板完全都是声明,应该只有 .h
,而不含有 .cpp
必须都放在头文件的实现包含:
- 函数模板;
- inline函数
- 带有default参数的声明.
- 类模板的成员函数
函数模板
参数匹配
1 |
|
- 如果有原生的完全匹配的函数,优先使用原生函数,例如
add(1, 2)
调用add(int, int)
。 - 其次,如果有模板能完全匹配的函数,使用模板生成函数,例如
add(1.1, 2.2)
调用add<double>(dobule, double)
。 - 再其次,尝试使用类型转换来匹配其他原生函数。但是,类型转换不能用于匹配模板,例如
add(1, 2.2)
。
类模板
简单的示例:
1 | template<typename T> |
Exception
异常的类型如何定义?
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
27
28
29
30 // 异常对象的定义
class DivisionError {
private:
string message;
public:
DivisionError(const string& msg) : message(msg) {
cout << "创建DivisionError异常对象" << endl;
}
~DivisionError() {
cout << "销毁DivisionError异常对象" << endl;
}
string what() const {
return message;
}
};
// 内层函数
double divide(double a, double b) {
cout << "进入divide函数" << endl;
Resource r("divide函数的局部资源");
if (b == 0) {
throw DivisionError("除数不能为零");
}
cout << "divide函数正常返回" << endl;
return a / b;
}
执行流程:
- 通过
throw
创建对应的异常对象 - 将异常所在的内层函数的资源释放;
- 再被外层的
catch
所捕获
在 catch
块中可以通过 throw;
再次抛出当前的异常
自定义异常类时,通常应该继承自 std::exception
或其派生类
异常规范
在函数原型中声明可能返回的异常类型:
1 | void print(Document& p) throw(PrintOffLine, BadDocument); |
如果函数返回了规范之外的异常,将调用
std::unexpected()
处理(默认调用std::terminate()
终止程序)
noexcept
也可以作为运算符使用,检查表达式是否声明为不抛出异常:
1 | bool willNotThrow = noexcept(func()); // 检查func()是否声明为noexcept |
层次结构
1 | std::exception |
- 数组的
.at
可以自动抛出数组访问异常的range_error
; - 容器的
resize
方法可以自动抛出长度异常length_error
;
构造与析构
析构函数应该避免抛出异常,否则会导致系统调用 std::terminate()
由于在 try-catch
中,如果发生了异常,本地变量将自动调用自己的析构函数,此时如果存在 new
申请的空间资源,将导致其无法指向正确的地址
因此,我们采取两阶段的构造确保构造函数不会抛出异常:
- 在构造函数内对基本变量赋值
- 在
init()
函数中显式申请内存空间
1 | class Widget { |
知识蒸馏
将个人印象比较浅的部分重新摘了一遍
- 字符指针与字符数组
1 | char *sp = "Hello World!"; // 字符指针可以移动,不能修改 |
实际上,
char *sp
就是const char *sp
,所以不能改变字符串的值,但是可以改变sp的指向而字符串数组的数组名是栈中的固定地址,无法移动,但是可以修改
不同文件之间的全局变量, 初始化的前后顺序由链接器随机决定. 此时需要确保它们之间没有初始化的依赖.
返回类型的常量
1
2const int* f();
// 只能将函数的返回值赋值给一个 const int*
vptr的大小
如果父类具有 virtual
也就是虚函数,子类继承之后也会得到一个虚函数表,对应有一个vptr指针指向自己的虚函数表:
- 在64位的机器下,一个vptr指针的大小是8字节
- 普通函数不占据类的大小,因为函数地址存放在全局空间
- 如果类内没有成员变量,也没有虚函数,那么大小就是1(告诉编译器这个类的存在)
编译器的对齐
- C++ 编译器会对类进行按最大对齐的成员进行对齐
1 |
|
对应的输出:
1 | Sizeof Nothing: 16 |
我们可以观察到两个现象:
size
=16说明了对齐现象;- Nothing对象的地址首先是其vptr,然后是其他的成员
期末题集
补天专用楼
程序填空
题目摘录
类模板:Array
1 |
|
函数模板:内积
此处的 op
操作之前没有接触过,利用的是标准库提供的二元操作:
1 |
|
类模板的填写
- 非内联定义成员函数时,需要在类型与函数名之间加上
<class-name><T>::
,不要忘记了其中的<T>
1 | template <typename T> |
长话短说
注意函数模板的返回类型:
1 |
|
此时第二个输出从 5.7 向 int转换,得到的结果是 5
如果是数组空间的管理, 注意 new
和 delete
都需要对应的 []
如果没有显式定义任何的构造函数,那么编译器会自动创建一个默认构造函数
- 但是如果程序员定义了任何的构造函数(无论是否带有默认参数),编译器就不用自动创建默认构造函数
upcast:将一个派生类的指针或者引用赋值给基类的指针或引用
- 动态绑定:发生upcast之后,通过基类指针或引用调用虚函数时,实际调用的是指针或引用锁指向的对象的虚函数的实现;而不是根据指针或引用的静态类型
- 如果此时发生了析构,如果基类的析构函数是虚函数,就会先后调用子类和父类的析构函数;如果父类的析构函数不是虚函数,就只会调用父类的析构函数
- 为了确保子类的资源可以被释放,总是应当将类的析构函数作为虚析构函数
析构函数不允许被重载
- 事实上,析构函数的名称固定,并且没有参数,因此无法通过参数列表来区分不同的韩苏版本,自然也就无法重载
C++标准只规定了整数类型的相对顺序:
1 | sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long) |
- 也就是说, 可能存在 size上
int = long
的情况
A program is a bunch of objects telling each other how to do by sending messages
- 此处的消息在oop中指的就是通过调用对象内部的方法
一句话说不清楚的
重载与友元函数
完全无法重载的:
1
2
3
4. .* :: ?:
sizeof typeid
static_cast dynamic_cast const_cast
reinterpret_cast只能作为成员函数(无法作为友元函数)重载的:
1
=, (), [], ->, ->*
以及单目运算符
父类的构造函数

构造函数与普通的成员函数不同:
- 如果父类的构造函数被声明为
private
的,和普通的成员函数一样——只能被自己的成员和友元函数可以调用 - 如果父类的构造函数被声明为
protected
的,那么只有父类的成员、友元以及派生类的构造函数可以调用;- 这意味着此时无法直接在子类除了构造函数之外的地方来直接创建独立的父类对象
C++对编程范式的支持

- 过程式编程的特性包括:函数、全局变量、顺序执行、选择(if/else, switch)、循环等
- C就是过程式编程的一种,C++继承了C的特性,因此对过程式变成支持良好
- 声明式编程指的是“做什么”而非如何做,比如SQL、HTML、Swift等。C++和C是命令式语言,关注“如何做”
因此答案选C
对象切片
1 | C2* pC2 = new C2(); |
拷贝初始化
用基本类型的值来隐式调用构造函数以创建对象:
1 | class ResId { |
这个过程分为两步:
- 用 5 构造一个临时的ResId对象;
- 用这个临时对象初始化res
因此我们整体上可以说:
1 | An object of class ResId will be created by 5 |
禁止隐式的拷贝初始化
通过声明 explicit
来禁止上述通过值隐式调用构造函数:
1 |
|
输出:
1 | i |
子类对父类函数的重载与重写
子类如果重载或者重写了父类的同名函数,将无法通过子类的对象访问父类中的这些函数:
1 |
|
输出:
1 | B::F(double) |
但是我们可以通过在子类中声明 using A::F
来重新获得访问权限:
1 | class B : public A { |
此时的输出为:
1 | B::F(double) |
默认参数的静态绑定
- 虚函数:运行时多态(动态绑定)
- 默认参数:编译时确定(静态绑定)
1 |
|
输出:
1 | B |
为什么cpp要选择让静态参数实现静态绑定?
为了保持语言的一致性与可预测性,设计者让静态参数作为编译时期自动替换的值,避免在运行过程中动态替换
函数模板与模板特化
模板特化:在函数模板的基础上,如果我们希望对某个类型实现不一样的逻辑,就可以使用~
1 | template<typename T> |
- 因此,模板特化必须首先存在一个主模板
模板特化的结果无法被重载
1 |
|
将会输出: 1
- 如果我们只保留
void f(const int &i) { std::cout << 2; }
函数,将会触发类型转换,可以调用 - 如果我们只去除模板特化中的
const
, 就可以匹配(输出2
)
再比如:
1 | template<typename T> |
常量对象
- 静态函数同样参与函数重载,但是优先匹配非静态函数
- 静态函数不受
const
限制——即使没有被声明为const
也可以被常量对象调用
1 |
|
输出:
1 | f(double) |
如果存在完全匹配的普通函数,就会直接调用非静态函数:
1 | void f(int) const { |
此处的
void f(int) const
无法改为void f(double) const
, 否则与同名静态函数的参数完全一致
异常的 catch
顺序
catch
块的匹配是从上到下的,因此只要遇到第一个匹配的,后续的匹配就会结束
- 子类的对象可以被父类捕获
- 注意此时的动态绑定——如果父类层级(引用或者指针)在前,内部抛出的是子类的异常对象,捕捉之后调用的函数是子类的对象
1 |
|
输出:
1 | 由父类层级捕获到异常: Derived::what() |
如果将
catch
内部改为普通的对象,将会输出由父类层级捕获到异常: Base::what()
由此可见,我们应该遵循:将子类对象的捕捉放在其父类之前,最后是 catch(...)
- 标题: oop复习
- 作者: ffy
- 创建于 : 2025-06-21 18:55:00
- 更新于 : 2025-06-26 14:57:33
- 链接: https://ffy6511.github.io/2025/06/21/课程笔记/oop复习笔记/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。