OI-wiki 语言篇:C++ 类(class)完全指南——从结构体进阶到 OOP 实战
【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. (某大型游戏线上攻略,内含炫酷算术魔法)项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki
在 OI-wiki 的 C++ 语言体系中,类(class)是结构体的自然进阶:它不仅聚合成员元素,还允许定义成员函数,是面向对象编程(OOP)中"对象"这一核心概念的直接载体。本文以 docs/lang/class.md 为骨架,结合 OI-wiki 仓库中大量真实竞赛代码(如凸包、莫队、有限域实现),系统讲解类的定义、访问控制、成员函数、运算符重载、构造函数、析构函数与赋值语义,帮助读者在算法竞赛中熟练运用类来组织数据、简化代码,并为阅读 OI-wiki 全站代码打下基础。读完本文,你将能够独立定义自己的类、正确重载运算符接入std::sort等 STL 设施,并规避浅拷贝、隐式转换等经典坑点。
从结构体到类:OOP 的第一级台阶
在 OI-wiki 的教程体系中,结构体(struct) 被描述为"一系列称为成员元素的组合体",可视为自定义数据类型;而类则更进一步,不仅拥有成员元素,还拥有成员函数。二者关系可以用一句话概括:类是结构体的拓展。
在面向对象编程(OOP)中,对象就是类的实例,也就是变量。类相当于一份"蓝图",而对象是根据蓝图生产出的具体实体——这与结构体变量是结构体类型的实例完全一致。
值得注意的是 C++ 语言中的一个重要细节:struct关键字定义的其实也是类。上文所说的"结构体"定义来自 C 语言,C++ 出于历史原因保留并拓展了struct,使其行为与class高度一致。这一历史渊源也解释了为什么 docs/lang/struct.md 中会特别注明"本页描述的struct不同于 C 中struct,在 C++ 中struct被扩展为类似class的类说明符"。
从实际应用看,OI-wiki 的竞赛代码中,struct与class都被大量用于组织复合数据。例如 docs/misc/code/mo-algo/mo-algo_1.cpp 中莫队算法的查询结构体:
struct query { int l, r, id; bool operator<(const query &x) const { // 重载<运算符 if (l / maxn != x.l / maxn) return l < x.l; return (l / maxn) & 1 ? r < x.r : r > x.r; } } a[N];这里struct拥有成员变量和成员函数(operator<),正是 C++ 中struct即类的生动体现。稍后我们会看到,同样的写法在class中完全成立。
定义类:语法与基本示例
类使用关键字class或struct定义。语法模板与实例:
class ClassName { ... }; // Example: class Object { public: int weight; int value; } e[array_length]; const Object a; Object b, B[array_length]; Object *c;上例定义了一个名为Object的类,拥有两个成员元素weight和value,并在}之后用该类型定义了一个数组e。可以看到,类的使用方式与结构体"大同小异"——一旦类型定义完成,就可以像内置类型一样用它定义常量(const Object a;)、变量(Object b;)、数组(Object B[array_length];)和指针(Object *c;)。
定义类的指针与定义结构体指针的方式完全相同,使用类名*即可,例如:
struct Edge { Edge* nxt; };该类指针通常用于构建链表、邻接表等竞赛中常见的数据结构。
访问说明符:public / protected / private
与前文结构体示例不同,类的定义中出现了public,这属于访问说明符(access specifier),用于控制成员的可访问范围。C++ 提供三种访问说明符:
| 访问说明符 | 类内访问 | 派生类访问 | 类外访问 | 友元访问 |
|---|---|---|---|---|
public | ✅ | ✅ | ✅ | ✅ |
protected | ✅ | ✅ | ❌ | ✅ |
private | ✅ | ❌ | ❌ | ✅ |
其具体语义为:
public:该说明符之后的各个成员都可以被公开访问,无论类内还是类外均可访问;protected:之后的成员可以被类内、派生类或友元的成员访问,但类外不能访问;private:之后的成员只能被类内成员或友元访问,不能被类外或派生类访问。
关键区别在于默认访问级别:对于struct,所有成员默认public;对于class,所有成员默认private。这正是两种关键字除语法外最重要的行为差异,也是"宁可显式书写public:"这一竞赛编码习惯的根源。
关于友元与派生类的基本概念
理解protected与private的差异,需要引入两个基础概念:
- 友元(
friend):使用friend关键字修饰某个函数或类,可以使得被修饰者在不成为成员函数或成员类的情况下,访问该类的私有(private)或受保护(protected)成员。简单来说,只要带有这个类的friend标记,就可以访问其私有或受保护成员元素。OI-wiki 代码中也有实例,例如 docs/geometry/code/convex-hull/convex-hull_1.cpp 用friend声明了Point的运算符重载函数(详见下文"重载运算符")。 - 派生类(derived class):C++ 允许使用一个类作为基类,并通过基类派生出派生类,派生类(根据特定规则)继承基类中的成员变量和成员函数,从而提高代码复用率。派生类与基类呈现 "is" 关系——如猫(派生类)"is" 哺乳动物(基类)。
在继承语境下,private与protected的区别可以直观理解为:派生类可以访问基类的protected元素(public同理),但不能访问private元素。
访问与修改成员元素的值
访问与修改成员元素的方法与结构体完全一致:
- 对于变量,使用
.符号:var.weight = 5; - 对于指针,使用
->符号(等价于(*ptr).weight):ptr->weight = 5;
从源码看,OI-wiki 全站代码大量依赖这一访问方式,例如凸包实现中p[i].x、u.back().y等(见 docs/geometry/code/convex-hull/convex-hull_1.cpp)。
成员函数:让数据"活"起来
成员函数(member function),顾名思义,就是类中所包含的函数。它是类区别于纯数据聚合体的根本特征。OI-wiki 中常见的成员函数包括:
vector.push_back(); set.insert(); queue.empty();成员函数的定义与普通函数几乎无异,既可以在类内直接定义,也可以在类内声明、类外定义。类外定义时需要以类名::限定函数所属:
class Class_Name { ... type Function_Name(...) { ... } }; // Example: class Object { public: int weight; int value; void print() { cout << weight << endl; return; } void change_w(int); }; void Object::change_w(int _weight) { weight = _weight; } Object var;上例中的Object类有一个打印成员元素的print函数,以及一个更改成员元素weight的change_w函数。注意change_w采用"类内声明(void change_w(int);)+ 类外定义(void Object::change_w(int _weight) { ... })"的写法——和普通函数类似,成员函数也可以先声明、后定义。
若想调用var的print成员函数,使用var.print()即可。
从实战角度看,将数据与操作封装进同一个类,能显著提升代码的组织性与复用性。例如 docs/math/code/finite-field/finite-field_1.cpp 中实现有限域的FiniteField类,其成员变量p, k, mod与成员函数pow、inv、poly_gcd等紧密关联,形成了一个自洽的数学抽象。
重载运算符:为自定义类型赋予原生语义
何为重载
C++ 允许编写者为名称相同的函数或运算符指定不同的定义,这称为重载(overload)。其规则要点如下:
- 如果同名函数的参数种类、数量中的一者或多者两两不相同,则这些同名函数被看作是不同的;
- 如果两个同名函数的区别仅仅是返回值类型不同,则无法重载,此时编译器会拒绝编译;
- 如果在调用时不会出现混淆(指无法根据所填参数种类和数量唯一判断被调用函数,常发生在具有默认参数的函数中),编译器会根据调用时填写的参数判断应调用哪个函数;这一过程被称为重载解析(overload resolution)。
重载运算符可以部分程度上代替函数,简化代码。OI-wiki 给出经典示例——定义一个向量类并重载*、+、-,分别代表向量内积、向量加、向量减:
class Vector { public: int x, y; Vector() : x(0), y(0) {} Vector(int _x, int _y) : x(_x), y(_y) {} int operator*(const Vector& other) const { return x * other.x + y * other.y; } Vector operator+(const Vector&) const; Vector operator-(const Vector&) const; }; Vector Vector::operator+(const Vector& other) const { return Vector(x + other.x, y + other.y); } Vector Vector::operator-(const Vector& other) const { return Vector(x - other.x, y - other.y); } // 关于第4、5行表示为x、y赋值,具体实现参见后文"构造函数"。该例中重载运算符的模板大致可分为两部分:
/*类定义内重载*/ 返回类型 operator符号(参数){...} /*类定义内声明,在外部定义*/ 返回类型 类名称::operator符号(参数){...}其中operator*在类内直接定义并返回内积(标量int),而operator+、operator-采用"类内声明 + 类外定义"的形式,返回新的Vector。
运算符重载是竞赛代码中让自定义类型接入 STL 的关键。对于自定义的类,如果重载了某些运算符(一般来说只需要重载<这个比较运算符),便可以使用相应的 STL 容器或算法,例如sort。OI-wiki 中莫队算法的查询结构体正是这样做的(见 docs/misc/code/mo-algo/mo-algo_1.cpp):通过重载operator<实现分块排序,随后直接sort(a, a + m)排序查询区间。std::sort的第三参量自定义比较函数/比较器也可以达到类似效果,详见 docs/basic/stl-sort.md。
另一个值得借鉴的实战写法来自 OI-wiki 的凸包代码 docs/geometry/code/convex-hull/convex-hull_1.cpp:使用friend声明友元函数来重载+、-、*(点乘)、^(叉乘)乃至>>(输入),并将它们定义为template <class T> struct Point的成员,实现了模板化、类型安全的二维几何运算:
template <class T> struct Point { T x, y; Point(T x = 0, T y = 0) : x(x), y(y) {} friend Point operator+(const Point &a, const Point &b) { return {a.x + b.x, a.y + b.y}; } friend Point operator-(const Point &a, const Point &b) { return {a.x - b.x, a.y - b.y}; } // 点乘 friend T operator*(const Point &a, const Point &b) { return a.x * b.x + a.y * b.y; } // 叉乘 friend T operator^(const Point &a, const Point &b) { return a.x * b.y - a.y * b.x; } friend istream &operator>>(istream &is, Point &p) { return is >> p.x >> p.y; } };可以看到,重载后的Point可以直接书写a + b、a - b、a * b、a ^ b等数学表达式,让几何算法的代码与数学记号高度一致,可读性大幅提升。
C++ 中可以被重载的运算符全集如下(注意部分运算符如::、.、.*、?:、sizeof不可重载,下表未列出):
+ - * / % ^ & | ~ ! = < > += -= *= /= %= ^= &= |= << >> >>= <<= == != <= >= && || ++ -- , ->* -> () [] new new [] delete delete []构造函数:在实例化时设定初始值
默认构造函数(Default Constructor)
为在实例化变量时设定初始值,需要定义默认构造函数:
class ClassName { ... ClassName(...)... { ... } }; // Example: class Object { public: int weight; int value; Object() { weight = 0; value = 0; } };该例定义了Object的默认构造函数,在实例化Object类型变量时,将全部成员元素初始化为0。
关于隐式默认构造函数的规则需要特别注意:
- 若没有显式定义任何构造函数,编译器会自动生成一个隐式默认构造函数,并根据成员元素的类型进行初始化(与定义内置类型变量的行为相同);
- 在这种情况下,内置类型的成员元素是未初始化的,访问未初始化变量的结果是未定义的(并不知道会返回何值)——这是竞赛中常见的隐性 Bug 来源之一;
- 如果需要自定义初始化的值,可以再定义(或重载)构造函数。
定义(或重载)构造函数的补充说明
- 一般来说,默认构造函数是不带参数的,这区别于带参的构造函数;构造函数与默认构造函数的定义"大同小异",只是参数数量上的不同;
- 构造函数可以被重载(首次定义当然也算定义);
- 关键警告:如果已经定义了任意构造函数,编译器便不会再生成无参数的默认构造函数。这可能导致试图以默认方式(不填入初始化参数)构造变量的行为编译失败;
- 使用 C++11 或以上标准时,可以使用
{}进行变量的初始化。
关于{}列表初始化
使用{}进行初始化时,会用到std::initializer_list这一轻量代理对象。其初始化步骤大致如下(简化版,详细内容可参见 cppreference 的 value initialization 文档):
- 尝试寻找参数中有
std::initializer_list的构造函数,如果有则调用(调用完后不再进行下面的查找); - 尝试将
{}中的元素填入其他构造参数,如果能将参数按照顺序填满(默认参数也算在内),则调用该构造函数; - 若无
private成员元素,则尝试在类外按照元素定义顺序或下标顺序依次赋值。
下面是一个结合默认构造函数、带参构造函数与{}初始化的完整示例:
class Object { public: int weight; int value; Object() { weight = 0; value = 0; } Object(int _weight = 0, int _value = 0) { weight = _weight; value = _value; } // the same as // Object(int _weight,int _value):weight(_weight),value(_value) {} }; // the same as // Object::Object(int _weight,int _value){ // weight = _weight; // value = _value; // } Object A; // ok Object B(1, 2); // ok Object C{1, 2}; // ok,(C++11)注意示例中的两种等价写法:一是类内使用初始化列表: weight(_weight), value(_value) {}初始化成员;二是将构造函数放到类外定义,写为Object::Object(int _weight, int _value) { ... }。初始化列表在 C++ 中通常比函数体内赋值更高效(直接初始化而非"默认构造 + 赋值"),竞赛代码中也常见这种风格,例如 docs/geometry/code/convex-hull/convex-hull_1.cpp 的Point(T x = 0, T y = 0) : x(x), y(y) {}。
关于隐式类型转换与 explicit
有时会写出如下代码:
class Node { public: int var; Node(int _var) : var(_var) {} }; Node a = 1;这看上去不符合逻辑——一个int类型怎么能转化为Node类型?但编译器不会给出error提示。原因在于:赋值时首先将1作为参数调用Node::Node(int),然后调用默认的复制(拷贝)函数完成赋值。
但大多数情况下,编写者希望编译器对此报错。这时可以在构造函数前追加explicit关键字,告诉编译器必须显式调用构造函数:
class Node { public: int var; explicit Node(int _var) : var(_var) {} };也就是说,加上explicit后,Node a = 1;将会报错,而Node a = Node(1);不会——后者显式调用了构造函数。不过,在算法竞赛中,为避免此类情况,常用的做法是"加强对代码的规范程度",从源头上避免写出依赖隐式转换的代码。
析构函数:管理动态内存的生命周期
变量销毁是编程中不可避免的问题——每一个变量都将在其作用范围结束时走向销毁。
但对于已经指向动态申请内存的指针来说,指针变量销毁时不会自动释放所指向的内存,需要手动释放动态内存。如果类的成员元素包含指针,同样会遇到这个问题,此时需要用到析构函数来手动释放动态内存。
析构函数(Destructor)将在该变量被销毁时被自动调用。其重载方法形同构造函数,但需要在类名前加~:
class Object { public: int weight; int value; int* ned; Object() { weight = 0; value = 0; } ~Object() { delete ned; } };上例中,Object拥有指针成员ned,在构造函数之外重载了析构函数~Object(),在对象销毁时执行delete ned;释放动态内存。
竞赛实践中的经验法则是:默认定义的析构函数通常对算法竞赛已经足够,通常只有在成员元素包含指针时才需要重载析构函数。
为类变量赋值:复制语义与浅拷贝陷阱
默认情况下,对类变量赋值时,会按照对应成员元素的赋值规则逐个进行。也可以使用类名称()或类名称{}作为临时变量来进行赋值:
// A,tmp1,tmp2,tmp3类型为Object tmp1 = A; tmp2 = Object(...); tmp3 = {...};两种临时变量写法的细微差别在于:
- 前者(
Object(...))只是调用了复制构造函数(copy constructor); - 后者(
{...})在调用复制构造函数前会先调用默认构造函数。
另一个必须牢记的重要语义是浅拷贝(shallow copy):默认情况下,进行的赋值都是对应元素间的浅拷贝。如果成员元素中有指针,则赋值完成后,两个变量的成员指针具有相同的地址——这意味着修改或释放其中一个会影响另一个,甚至引发重复释放(double free)等严重问题。
如需解决指针问题或进行更多自定义操作,需要重载相应的构造函数(复制构造函数、移动构造函数等)。关于构造函数(constructor)的更多内容,可参见文末参考资料第 6 条。
总结:类在 OI-wiki 与竞赛中的使用要点
- 类是结构体的拓展:
class与struct在 C++ 中都是类说明符,差异主要体现在默认访问级别(class默认private,struct默认public)与书写惯例上; - 访问控制:
public/protected/private决定了成员在类内、派生类、友元与类外四种场景下的可访问性,是封装的基础; - 成员函数让数据与操作内聚;重载运算符(尤其是
operator<)是接入std::sort等 STL 设施的关键,OI-wiki 的莫队、凸包等实现都是范例; - 构造函数控制初始化语义,注意"定义了带参构造函数后编译器不再生成默认构造函数"的陷阱,C++11 起可用
{}与std::initializer_list初始化; - 析构函数用于释放动态内存,通常仅当类包含指针成员时才需重载;
- 赋值默认是浅拷贝,含指针成员时必须重载相应构造函数以避免共享地址引发的错误。
在 OI-wiki 中,类相关的代码散见于 docs/math/code、docs/geometry/code、docs/misc/code 等目录,读者可将本文与这些源码对照阅读,进一步体会类的实战用法。
参考资料
以下为本页对应的官方标准参考(原文档所列,供深入学习):
- cppreference: class
- cppreference: access
- cppreference: default_constructor
- cppreference: operators(运算符重载)
- cplusplus: Data structures(结构体教程)
- cplusplus: Special members(特殊成员函数)
- C++11 FAQ(Stroustrup)
- cplusplus: Friendship and inheritance(友元与继承)
- cppreference: value initialization(值初始化,对应
{}初始化步骤的完整规则)
关联阅读:结构体(struct)、STL 排序(std::sort)、C++ 语言入门总览
【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. (某大型游戏线上攻略,内含炫酷算术魔法)项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考