$ cat ~ / posts /c++ /oop1 3.2k Words ~ 13 Mins
cover.png
面向对象程序设计01

#面向对象程序设计01

exdoubled Lv5

编译环境和参考资料

本课程使用 C++23 标准。推荐编译器版本如下:

  • GCC 14 或更高版本
  • Clang 18 或更高版本
  • MSVC 19.37,也就是 Visual Studio 2022 17.7 或更高版本

编译时需要使用:

1
g++ -std=c++23 main.cpp

也可以用 Compiler Explorer 在线编译和查看生成的汇编代码

C++ 是一门强调性能和底层控制的语言,很多时候只看源代码不够,还要知道它最终大概会生成什么样的机器级行为

第一个 C++23 程序

1
2
3
4
5
6
7
8
9
10
11
12
#include <print>
#include <string>

int main() {
std::string name = "Alice";
int age = 20;
double score = 95.5;

std::println("name : {}", name);
std::println("age : {}", age);
std::println("score: {}", score);
}

这段程序很短,但已经包含了几个重要概念。

#include <print> 告诉编译器需要使用 C++23 的格式化输出工具,#include <string> 引入标准库字符串类型

int main() 是程序入口,C++ 程序从 main 函数开始执行

int 表示这个函数返回一个整数,通常返回 0 表示程序正常结束;如果 main 结尾没有显式写 return 0;,C++ 会把它视为正常返回

std::string name = "Alice"; 创建了一个名叫 name 的变量,类型是 std::string,初始值是 `“Alice”``

`std::println("name : {}", name); 会把 {} 替换成后面的变量值,并自动换行

输入和输出

C++23 新增了 std::printstd::println

1
2
std::println("Hello, {}!", name); // 格式化输出并自动换行
std::print("{} ", value); // 格式化输出但不换行

它们比传统的 std::cout << ... 更接近现代语言里的格式化输出,也更容易读

输入仍然常用 std::cinstd::getline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <print>
#include <string>

int main() {
std::string name;
int age;

std::print("name: ");
std::getline(std::cin, name);

std::print("age : ");
std::cin >> age;

std::println("Hello, {}! Your age is {}.", name, age);
}

std::cin >> age 适合读取一个值,例如整数、浮点数或一个不含空格的单词std::getline(std::cin, name) 读取一整行,因此可以包含空格

这里要注意两类输入方式混用时,换行符残留可能导致 getline 读到空行

变量

1
int age = 20;

程序会做如下三件事:

  • 在内存中分配一块空间,int 通常占 4 字节
  • 给这块空间起名为 age
  • 把整数值 20 存入这块空间

内存视图可以粗略写成:

1
2
3
地址       内容    名字
0x1000 [20] age
0x1004 [??] 其他内存

变量名是给程序员看的标签。程序编译之后,机器并不会按变量名查找数据,而是通过地址和偏移访问内存

不要使用未初始化变量

C++ 中读取未初始化的局部变量通常是未定义行为,结果可能看起来“偶尔能跑”,但这不是正确和好的

定义变量时尽量同时初始化:

1
2
3
int count{};       // 初始化为 0
double score{}; // 初始化为 0.0
std::string name; // 默认构造为空字符串

类型

类型规定了:

  • 这块内存中的比特应该怎样被解释
  • 这个值支持哪些操作

同样的二进制内容,如果按 int 解释、按 float 解释、按字符数组解释,得到的含义完全不同

常见类型如下:

类型含义典型大小示例
int整数4 字节int x = 42;
double浮点数8 字节double pi = 3.14;
char单个字符1 字节char c = 'A';
bool布尔值1 字节bool ok = true;
std::string文本对象本身固定大小,内容可变长std::string s = "hello";

std::string 不是 C 语言里的 char*

一个 std::string 对象本身有固定大小,常见实现大约 24 到 32 字节;短字符串可能直接存放在对象内部,长字符串通常会在堆上另外申请空间,再由对象内部的指针指过去。我们使用它时不需要手动 mallocfree,这是标准库封装资源管理的价值

类型决定操作

类型决定了表达式能不能写,也决定了表达式是什么意思:

1
2
3
4
5
6
7
int a = 10;
int b = 3;
double c = 10.0;
double d = 3.0;

int r1 = a / b; // 结果是 3
double r2 = c / d; // 结果约为 3.333333

a / b 是整数除法,小数部分会被丢弃

c / d 是浮点除法,会保留小数。它们看起来都是 /,但由于操作数类型不同,语义不同

auto

C++ 允许用 auto 让编译器根据初始值推断类型:

1
2
3
auto x = 42;      // int
auto y = 3.14; // double
auto p = "hello"; // const char*,不是 std::string

auto 不是动态类型,也不是“没有类型”

变量仍然有确定的静态类型,只是这个类型由编译器从初始化表达式推断出来

对字符串字面量:

1
2
auto a = "hello";              // const char*
auto b = std::string{"hello"}; // std::string

如果希望得到 std::string,就要显式构造

不要只因为写了 auto 就以为它会推断成“最符合直觉”的类型

分支、循环和范围 for

1
2
3
4
5
6
7
8
9
int score = 85;

if (score >= 90) {
std::println("Excellent");
} else if (score >= 70) {
std::println("Good");
} else {
std::println("More work");
}
1
2
3
4
5
6
7
8
9
for (int i = 0; i < 5; ++i) {
std::print("{} ", i);
}

int n = 1;
while (n < 100) {
n = n * 2;
}
std::println("{}", n);

C++ 中更推荐在合适场景下使用范围 for

1
2
3
4
5
6
7
8
9
10
#include <print>
#include <vector>

int main() {
std::vector<int> numbers = {10, 20, 30, 40, 50};

for (int x : numbers) {
std::print("{} ", x);
}
}

std::vector<int> 是一个动态数组,可以保存任意数量的 int 元素

for (int x : numbers) 的语义是:依次取出容器中的每个元素,复制一份给 x,然后执行循环体

如果元素很小,例如 int,复制成本几乎可以忽略;如果元素是一个很大的对象,每次循环都复制就可能很慢。这时可以用引用:

1
2
3
for (const auto& x : numbers) {
std::print("{} ", x);
}

这表示不复制元素,而是用引用直接观察容器里的对象,并且 const 承诺不会修改它

函数

函数把一段逻辑包装成一个可以反复调用的单元:

1
2
3
4
5
6
7
8
9
10
11
#include <print>

double circle_area(double radius) {
return 3.14159 * radius * radius;
}

int main() {
double r = 5.0;
double area = circle_area(r);
std::println("Area: {}", area);
}

在这个例子中,r 的值会复制给参数 radius

1
2
3
调用前:r = 5.0,存在 main 的栈帧中
调用时:radius = 5.0,是 circle_area 的局部参数
返回后:radius 被销毁,area 保存返回值

因此,默认的值传递不会修改调用者的变量:

1
2
3
4
5
6
7
8
9
void double_value(int x) {
x *= 2;
}

int main() {
int a = 5;
double_value(a);
std::println("{}", a); // 仍然是 5
}

函数声明和定义

C++ 中,在调用一个函数之前,编译器必须已经知道它的签名:

1
2
3
4
5
6
7
8
9
double square(double x); // 声明

int main() {
double y = square(3.0);
}

double square(double x) { // 定义
return x * x;
}

函数声明告诉编译器:有这样一个函数,它叫什么,返回什么类型,参数是什么类型,函数定义提供完整实现

大型项目通常把声明放在头文件,把定义放在源文件,这样不同源文件可以共享接口,而不必互相复制实现

类和对象

类是 C++ 中组织数据和操作的核心机制

允许创建新的类型,把数据和操作这些数据的函数放在一起

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
#include <print>
#include <string>

class BankAccount {
private:
std::string owner_;
double balance_;

public:
void setup(std::string owner, double initial_balance) {
owner_ = owner;
balance_ = initial_balance;
}

void deposit(double amount) {
balance_ += amount;
}

void withdraw(double amount) {
balance_ -= amount;
}

double get_balance() const {
return balance_;
}

void print() const {
std::println("{}'s balance: {}", owner_, balance_);
}
};

类是蓝图,对象是按蓝图创建出来的实体:

1
2
3
4
5
6
7
8
9
10
int main() {
BankAccount alice;
alice.setup("Alice", 1000.0);
alice.deposit(500.0);
alice.print();

BankAccount bob;
bob.setup("Bob", 2000.0);
bob.print();
}

alicebob 是同一个类的两个对象,但它们在内存中是两份独立的数据。修改 alice 的余额不会影响 bob

private、public 和约束

private 表示只能在类内部访问,外部代码不能直接读写:

1
2
3
4
BankAccount alice;
alice.setup("Alice", 0.0);
alice.balance_ = -9999; // 编译错误
alice.deposit(100); // 正确

const 成员函数也是一种约束:

1
2
3
double get_balance() const {
return balance_;
}

函数末尾的 const 承诺:这个成员函数不会修改对象状态

编译器会检查这个承诺。如果在 const 成员函数里修改普通数据成员,编译器会报错

指针

每个变量都位于内存中的某个位置,这个位置可以用地址表示:

1
2
3
4
5
6
#include <print>

int main() {
int x = 42;
std::println("{}", static_cast<void*>(&x));
}

&x 表示 x 的地址。指针变量保存的就是地址:

1
2
3
4
5
int x = 42;
int* p = &x;

std::println("{}", static_cast<void*>(p)); // 地址
std::println("{}", *p); // 42

p 的类型是 int*,意思是“指向 int 的指针”

*p 是解引用,意思是沿着 p 里保存的地址找到那块内存,并按 int 的方式读写它

1
2
*p = 100;
std::println("{}", x); // 100

内存视图如下:

1
2
3
4
5
6
7
修改前:
0x1000 [42] x
0x2000 [0x1000] p

执行 *p = 100 后:
0x1000 [100] x
0x2000 [0x1000] p

改变的是 p 指向位置里的值,不是 p 本身保存的地址

引用

引用是已存在变量的别名:

1
2
3
4
5
int x = 42;
int& r = x;

r = 100;
std::println("{}", x); // 100

从语言语义上说,通过引用操作,就是直接操作被引用的对象

引用有两个约束:

  • 声明时必须初始化
  • 一旦绑定到某个对象,就不能改绑到另一个对象

参考下面这段代码:

1
2
3
4
5
int a = 1;
int b = 2;
int& r = a;

r = b;

最后一行不是让 r 改为引用 b,而是把 b 的值赋给 a

执行后 a == 2r 仍然是 a 的别名

指针和引用的区别

问题指针 int*引用 int&
可以为空吗可以,常用 nullptr不可以,必须绑定到有效对象
可以改变指向吗可以不可以
使用时需要特殊符号吗需要 *p 解引用不需要,像普通变量一样用
声明时必须初始化吗不一定必须

引用常用于函数参数:

1
2
3
4
5
6
7
8
9
void double_ref(int& x) {
x *= 2;
}

int main() {
int b = 5;
double_ref(b);
std::println("{}", b); // 10
}

函数签名本身就是语义声明:

1
2
void f(int x);   // 我拿到的是副本,通常不会改你的原变量
void g(int& x); // 我拿到的是别名,可能修改你的原变量

const T&,它表示“我不想复制这个对象,也承诺不修改它”

标准库容器和字符串

std::vector 是动态数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <print>
#include <vector>

int main() {
std::vector<int> v;
v.push_back(10);
v.push_back(20);
v.push_back(30);

std::println("{}", v[0]);
std::println("{}", v.size());

for (int x : v) {
std::print("{} ", x);
}
}

当不断 push_back 时,std::vector 会根据需要在内部申请更大的连续内存,并在自身销毁时释放。使用者通常不需要手动 newdelete

std::string 是文本类型:

1
2
3
4
5
6
7
8
9
10
11
12
#include <print>
#include <string>

int main() {
std::string s1 = "Hello";
std::string s2 = "World";
std::string s3 = s1 + ", " + s2 + "!";

std::println("{}", s3);
std::println("{}", s3.length());
std::println("{}", s3[0]);
}

和 C 字符串相比,std::string 的优势是资源管理和语义更清楚:它知道自己的长度,支持拼接、比较、查找,也会自动管理内部内存

AI 时代怎么学 C++

  • 多看:读成熟项目的代码,理解别人为什么这样组织类型、函数和接口
  • 多编:代码必须经过编译、链接和运行,不是脑子里觉得对就对
  • 多查:标准库和语言规则很多,不能只靠记忆,cppreference 应该放在优先位置
  • 多问但要会质疑:AI 可以解释、改错、review,但它也会编造或遗漏细节

对于 C++ 尤其要避免只停留在“抽象语法”层面

C++ 代码最终会落到对象、内存、地址、拷贝、析构、函数调用这些具体行为上。专业训练的价值就在于能穿过语法表面,看见背后的成本和约束

$ discussion
# Comments
waline