C++ 入门教程
C++程序的基石 —— 词法约定
欢迎来到C++的世界!在我们开始建造宏伟的软件大厦之前,首先需要认识一下构成这座大厦的最基本的“砖块”和“钢筋”。在编程语言中,这些最基本的元素被称为词法元素或标记 (Token)。本章将带你认识这些基础构件,理解编译器是如何阅读我们编写的代码的。
万物之始:标记 (Tokens) 与字符集
想象一下我们正在阅读一篇中文文章。这篇文章由一个个汉字、标点符号、空格和换行符组成。C++程序也是如此,它的源代码由标记 (Tokens)和空格 (Whitespace)组成。
标记是编译器能够理解的最小有意义单元。 就好比是中文里的“词语”。C++中的标记主要有以下几类:
- 关键字 (Keywords):如
int,if,for,是C++语言内定有特殊含义的词。 - 标识符 (Identifiers):如我们给变量、函数取的名字。
- 字面量 (Literals):如数字
123、3.14,文字"Hello"等表示固定值的“数据”。 - 运算符 (Operators):如
+,-,*,/,用于执行计算。 - 标点符号 (Punctuators):如
;,{,},用于组织代码结构。
编译器在正式分析你的代码逻辑前,第一步工作就是把你的源代码文本文件“打散”成一个个独立的标记。而像空格、回车、制表符(Tab键)这类空白字符,主要是为了分隔标记,让代码更易读,编译器在分析时通常会忽略它们。
你知道吗?—— 字符集
我们的代码本质上是一个文本文件,需要用一种编码方式来保存。C++标准规定了一套基本源字符集,包含了96个可以在源文件中使用的标准字符,包括26个大小写英文字母、10个数字以及一些常见的符号。 这保证了最基础的C++代码在世界各地的计算机上都能被正确读取。对于像汉字这样的非基本字符,现代C++也通过通用字符名称(如
\uXXXX)和Unicode编码(如UTF-8)提供了很好的支持。虽然一些特定的编译器(如微软的MSVC)可能允许在标识符中使用
$等额外字符,但为了保证代码在任何平台都能正常编译,我们强烈建议初学者优先使用标准的英文字符来编写代码,以保证最好的兼容性。
给代码加点“说明书”:注释 (Comments)
注释是代码中非常重要的一部分,它是写给程序员自己或同事看的,编译器会完全忽略它们。 好的注释可以极大地提高代码的可读性,帮助我们在很久之后还能快速理解代码的意图。
C++中有两种注释方式:
单行注释:以
//开始,直到这一行的末尾都是注释。cpp// 这是一个单行注释。它通常用于解释下面一行或一小段代码的功能。 int age = 25; // 也可以用在代码行的末尾,对该行进行补充说明。多行注释:以
/*开始,以*/结束,可以跨越多行。 这种语法与C语言相同。cpp/* 这是一个多行注释。 所有在这里面的内容, 无论多少行,都会被编译器忽略。 常用于函数或文件的整体说明。 */
新手提示:养成随时写注释的好习惯!对于现在还看不太懂的代码,可以先用注释标记下来你的理解和疑问。
给万物起个名:标识符 (Identifiers)
在程序中,我们需要给变量(存放数据)、函数(执行操作)、类(自定义数据类型)等很多东西起名字。这些名字就叫做标识符 (Identifiers)。
给一个东西命名,总得有点规矩。C++标识符的命名规则如下:
- 可以包含:大小写字母 (
a-z,A-Z)、数字 (0-9) 和下划线 (_)。 - 必须以:字母或下划线开头,不能以数字开头。
- 大小写敏感:
myVariable和myvariable是两个完全不同的标识符。 - 不能是关键字:你不能使用
int、class等关键字作为标识符名称。
这里有一些例子:
// 合法的标识符
int score;
int user_age;
int level2_boss;
int _private_data; // 通常下划线开头的标识符有特殊用途,初学阶段建议避免使用
// 非法的标识符
// int 2nd_player; // 不能以数字开头
// int my-score; // 不能包含连字符'-'
// int double; // 不能使用关键字为了代码的可移植性,我们应该始终遵循C++标准,避免使用特定编译器支持的非标准字符(如 $)。
命名建议:为了让代码更易读,建议采用有意义的单词来命名,例如使用 userName 而不是 un。常见的命名风格有:
- 驼峰命名法 (Camel Case):
myVariableName - 帕斯卡命名法 (Pascal Case):
MyClassName - 下划线命名法 (Snake Case):
my_variable_name选择一种并坚持使用,能让你的代码风格更统一。
语言的内置词汇:关键字 (Keywords)
关键字 (Keywords) 是C++语言预先定义并保留的标识符,它们有特殊的语法含义。 你不能将它们用作变量名或函数名。
你不需要一次性背下所有关键字,随着学习的深入,你会自然而然地熟悉它们。这里列举一些最常见的:
- 表示类型的:
int,double,char,bool,void,auto - 控制流程的:
if,else,switch,for,while,return,break - 定义类型的:
class,struct,enum - 访问控制的:
public,private,protected - 其他:
const,static,new,delete,true,false
完整的关键字列表可以在文档中查阅,但现阶段你只需要知道它们是“被语言占用了的特殊单词”即可。
代码中的固定值:字面量 (Literals)
字面量 (Literal) 是指在程序中直接表示一个固定值的元素。 简单来说,就是你写在代码里的“数据”本身。
整数 (Integer) 字面量:
- 十进制:
100,42,0(最常用的形式)。 - 十六进制: 以
0x或0X开头,如0xFF(等于十进制的255)。 - 八进制: 以
0开头,如010(等于十进制的8)。初学者容易混淆,建议少用。 - 二进制 (C++14起): 以
0b或0B开头,如0b1010(等于十进制的10)。 - 为了方便阅读,可以在数字间使用单引号
'作为分隔符,编译器会忽略它,如3'000'000。
- 十进制:
浮点数 (Floating-point) 字面量:
- 带小数点的数字,如
3.14159,0.5,18.0。 - 也可以使用科学计数法,如
1.846e1(表示 1.846 x 10¹,即 18.46)。
- 带小数点的数字,如
布尔 (Boolean) 字面量:
- 只有两个:
true(真) 和false(假)。
- 只有两个:
指针 (Pointer) 字面量:
nullptr(C++11起),表示一个不指向任何地方的“空指针”。 这是一个非常重要的概念,我们将在后续章节深入学习。
字符 (Character) 和字符串 (String) 字面量:
- 字符字面量: 用单引号
'括起来的单个字符,如'A','%','\n'(表示换行符)。 - 字符串字面量: 用双引号
"括起来的一串字符,如"Hello, C++!","你好"。 字符串的末尾会自动添加一个不可见的“空字符”(\0)作为结束标记。 - 转义序列: 在字符和字符串中,反斜杠
\用来表示一些特殊的字符,例如:\n: 换行符\t: 水平制表符 (Tab)\\: 反斜杠本身\": 双引号\': 单引号
- 字符字面量: 用单引号
深入一点:字面量类型后缀
有时我们希望明确告诉编译器一个数字是什么类型。例如,
123默认是int类型,但123L就表示它是一个long类型的整数,123U表示unsigned int,123ULL表示unsigned long long。 浮点数3.14默认是double类型,3.14f则表示float类型。 初学阶段了解即可,暂时不必深究。
组织与运算的符号:标点与运算符
除了“词语”,C++也需要“标点符号”来组织语句和代码块。这些符号本身不产生计算值,但对编译器有重要的语法意义。
常用标点:
;(分号): 语句的结束符,如同中文的句号。{ }(花括号/大括号): 用于创建代码块,界定函数体、循环体等。( )(圆括号): 用于函数调用、改变运算优先级等。[ ](方括号): 用于数组访问。< >(尖括号): 用于模板。#(井号): 预处理器指令的标志。
运算符:
- 我们已经见过一些,如
+,-,=,<。C++拥有丰富的运算符,我们将在后续章节详细学习它们的功能和用法。
- 我们已经见过一些,如
小试牛刀:你的第一个C++程序
现在我们已经认识了构成C++程序的所有基本元素,让我们来看一个完整的“Hello, World!”程序,并尝试辨认出其中的各种“标记”。
#include <iostream> // #是标点,include是预处理指令,<iostream>是头文件名
// main函数是每个C++程序的入口点
int main() // int是关键字, main是标识符, ()是标点
{ // { 是标点,开启一个新的代码块
// 使用std::cout将字符串输出到控制台
std::cout << "Hello, World!" << std::endl; // std, cout, endl是标识符; ::, <<是运算符; "Hello, World!"是字符串字面量; ;是标点
return 0; // return是关键字; 0是整数字面量; ;是标点
} // } 是标点,结束代码块C++的核心骨架与内置类型
在第一部分中,我们认识了构成C++代码的“砖块”——标记。现在,是时候学习如何使用这些砖块来搭建程序的骨架了。本章将介绍C++中一些至关重要的基本概念,它们是理解后续所有编程知识的基础。
万物皆有类型:C++类型系统
在C++中,类型 (Type) 的概念至关重要。你可以把“类型”想象成一个特定形状和大小的盒子:
- 它规定了需要多少内存空间来存放数据。
- 它定义了可以存入什么样的数据(比如只能放整数,或者只能放文字)。
- 它决定了你可以对这些数据执行哪些操作(比如整数可以做加减乘除,而文字不行)。
C++是一门静态类型、强类型的语言。这意味着:
- 静态类型:每个变量、函数参数和返回值的类型都必须在编译时(即程序运行前)确定下来,并且永远不会改变。
- 强类型:编译器会严格检查类型。你不能把一个字符串赋值给一个整数变量,除非进行了明确的类型转换。
声明和初始化变量
当你想要在程序里存储数据时,就需要声明 (Declare) 一个变量 (Variable)。声明时,你必须指定它的类型和名称。一个好习惯是,在声明的同时对其进行初始化 (Initialize),也就是给它一个初始值。
// 语法: type variable_name = initial_value;
int result = 0; // 声明并初始化一个名为 result 的整型变量。
double coefficient = 10.8; // 声明并初始化一个名为 coefficient 的浮点型变量。
auto name = "Lady G."; // 使用 auto 关键字,让编译器根据初始值 "Lady G." 自动推断 name 的类型。
// 注意!auto 在这里推断出的类型是 const char*(C风格字符串指针),而不是 std::string 对象。
// 这是初学者使用auto时最常见的误解之一。
// 错误示例
// auto address; // 错误! 使用 auto 必须提供初始值,否则编译器无法推断类型。
// age = 12; // 错误! 变量在使用前必须先声明其类型。
// result = "Kenny G."; // 错误! 不能将一个字符串字面量赋值给一个已经声明为 int 的变量。
int maxValue; // 不推荐! 只声明不初始化,maxValue 的值是随机的“垃圾值”,
// 这可能导致难以预料的程序行为。C++的内置“积木”:基本(内置)类型
C++语言内置了一些基础的数据类型,我们称之为基本类型 (Fundamental Types) 或内置类型 (Built-in Types)。你不需要包含任何头文件就可以直接使用它们。它们主要分为三大类:整型、浮点型和void。
空类型:void
void 是一个特殊的类型,表示“无类型”或“空”。你不能声明一个void类型的变量。它最常见的用途是当函数不返回任何值时,将其返回类型声明为 void。
逻辑判断:布尔类型 (bool)
bool 类型用于表示逻辑值,它只有两个可能的常量值:true (真) 和 false (假)。 它是进行逻辑判断和流程控制的基础。
bool isGameOver = false;
if (isGameOver) {
// ...
}存储整数:整型家族
整型用来表示没有小数部分的整数。 int 是默认的基础整数类型。
| 类型名称 | 常见大小 | 描述和用途 |
|---|---|---|
int | 4 字节 | 整数 (integer) 值的默认选择。用于存储没有小数部分的数字,如年龄、数量、年份等。 |
short | 2 字节 | 用于存储较小范围的整数,节省内存。 |
long | 通常 4 字节 | 用于存储较大范围的整数。 |
long long | 8 字节 | 用于存储非常大的整数。 |
新手建议:在不确定时,优先使用 int。如果需要存储非常大的数字,再考虑使用 long long。
大多数数值类型还可以用 signed (有符号,默认) 和 unsigned (无符号) 来修- unsigned int 的范围大约是 0 到 +42亿。
存储小数:浮点类型家族
浮点类型用于表示带有小数部分的数值。
| 类型 | 常见大小 | 描述 |
|---|---|---|
double | 8 字节 | 双精度浮点数。是浮点值的默认选择,具有更好的精度。 |
float | 4 字节 | 单精度浮点数。精度较低,占用空间小。 |
long double | 通常 8 或 16 字节 | 扩展精度浮点数。 |
新手建议:优先使用 double 来处理所有带小数的计算,以获得足够的精度。
存储字符:字符类型家族
| 类型 | 大小 | 描述 |
|---|---|---|
char | 1 字节 | 基础字符类型。足以存储本地的ASCII字符,如 'A', '?', '5'。 |
wchar_t | 2 或 4 字节 | 宽字符类型。用于存储像汉字这样的Unicode字符。 |
让值保持不变:const 限定符
有时我们希望某个变量的值在初始化后就不能再被修改。这时可以使用 const (constant,常量) 关键字来限定它。
const double PI = 3.14159; // 声明一个名为PI的常量
// PI = 3.0; // 错误! 编译器会阻止你修改一个 const 变量。使用 const 是一个非常好的编程习惯,它可以防止我们无意中修改了不该被修改的值,让程序更安全、更易于理解。
处理文本:字符串类型
严格来说,C++没有内置的字符串类型。但是,现代C++编程中,我们几乎总是使用标准库提供的 std::string 类型。它更安全、更方便。要使用它,你需要包含 <string> 头文件。
#include <string> // 引入 string 库
std::string greeting = "Hello, C++!"; // 声明并初始化一个字符串变量强烈建议初学者始终使用 std::string 来处理文本,避免使用底层的C风格字符数组。
初探内存地址:指针类型
指针 (Pointer) 是C++中一个强大但也颇具挑战性的概念。初学者需要理解其基本思想。
一个指针变量存储的不是数据本身,而是另一个变量在内存中的地址。
- 使用
*(星号) 来声明一个指针类型。 - 使用
&(地址运算符) 来获取一个变量的内存地址。 - 使用
*(解引用运算符) 来访问指针所指向地址上的数据。
int number = 10; // 一个普通的整型变量,值为10。
int* pNumber = &number; // 声明一个整型指针 pNumber。
// 它存储的是 number 变量的内存地址。
// 现在, *pNumber 和 number 是等价的
*pNumber = 41; // 通过指针修改了它所指向的内存。
// 这行代码执行后,number 变量的值也变成了 41。警告:原始指针的危险 直接使用像 int* 这样的原始指针 (Raw Pointers) 是危险的,因为程序员必须手动管理内存,很容易忘记释放,导致内存泄漏。
现代C++的解决方案:智能指针 为了解决这个问题,现代C++推荐使用智能指针 (Smart Pointers),如 std::unique_ptr。智能指针是一种特殊的对象,它包装了原始指针,并能在不再需要时自动释放内存。
作为初学者,你现在只需要知道:
- 指针是存储内存地址的变量。
- 直接使用原始指针进行内存管理是危险的,应尽量避免。
- 现代C++提供了更安全的智能指针作为替代方案。
名字的有效范围:作用域 (Scope)
作用域 (Scope) 是指一个名称(如变量名、函数名)在程序中可见和可以使用的区域。
- 局部作用域 (Local Scope):在函数或代码块(由
{}包围)内部声明的变量,只在该函数或代码块内部有效。 - 全局作用域 (Global Scope):在所有函数、类之外声明的变量,具有全局作用域。应尽量避免使用全局变量,因为它们会使程序难以理解和维护。
如果在一个内部作用域中声明了一个与外部作用域同名的变量,那么在内部作用域中,外部的变量将被“隐藏”。
#include <iostream>
int i = 7; // 全局变量 i
int main() {
int i = 5; // 局部变量 i,隐藏了全局的 i
std::cout << "局部 i 的值是: " << i << std::endl; // 输出 5
std::cout << "全局 i 的值是: " << ::i << std::endl; // 使用范围解析运算符 :: 来访问被隐藏的全局变量 i, 输出 7
}程序的起点:main 函数
每个C++可执行程序都必须有一个 main 函数。它是操作系统的入口点,即你的程序从这里开始执行。
main 函数最标准的形式是:
int main() {
// 你的代码从这里开始
return 0;
}int main(): 这被称为函数签名。它告诉编译器main函数会返回一个整数 (int)。return 0;: 这是main函数的返回语句。按照惯例,返回0表示程序成功执行完毕。返回非零值通常表示程序遇到了错误。
main 函数还可以接收命令行传入的参数,但这是更高级的用法,我们暂时不必关心。
灵活应变:类型转换
有时,我们需要在不同类型的值之间进行转换。类型转换分为两种:
隐式转换 (Implicit Conversion):由编译器自动执行。
- 从小类型转为大类型,通常是安全的,如
int转换为double。 - 从大类型转为小类型,可能丢失数据,编译器通常会对此发出警告。如
double转换为int,小数部分会被丢弃。
- 从小类型转为大类型,通常是安全的,如
显式转换 (Explicit Conversion / Casting):由程序员在代码中明确要求的转换。
在现代C++中,我们推荐使用 static_cast。当你确定收缩转换不会导致问题时,可以使用它来消除编译器的警告。
double d = 1.9;
// int j = d; // 会产生警告:可能丢失数据
// 使用 static_cast 明确告诉编译器:我知道会发生什么,请执行转换。
int j = static_cast<int>(d); // j 的值是 1,没有警告。养成好习惯:认真对待编译器的每一个警告。当你看到关于类型转换的警告时,要么修改你的逻辑,要么使用 static_cast 来表明你的意图。
赋予代码生命 —— 声明、定义与初始化
我们已经认识了C++的各种基本类型,现在是时候学习如何创建这些类型的实体(如变量和函数),并赋予它们生命了。本章将深入探讨两个在C++中既相似又有着本质区别的核心概念:声明与定义,以及如何正确地为变量赋予初始值。
蓝图与建筑:声明 (Declaration) vs. 定义 (Definition)
在日常生活中,建筑师先画出“蓝图”(设计),然后施工队才根据蓝图建造出“实体建筑”。在C++中,声明和定义的关系与此类似。
- 声明 (Declaration):就像是“蓝图”。它向编译器介绍一个名称,并告诉编译器这个名称是什么类型的(一个整数?一个函数?)。声明让编译器知道“哦,这个东西存在”,但并不为它分配内存。
- 定义 (Definition):就像是“建造”。它不仅完成了声明的工作,还会为这个实体分配实际的内存空间。对于函数来说,定义就是提供函数的具体实现代码。
一个实体可以被声明多次,但只能被定义一次。 这被称为“单一定义规则 (One Definition Rule, ODR)”。
// --- 声明 (Declarations) ---
extern int i; // 声明一个名为 i 的整数。extern 关键字告诉编译器,
// 这个变量的“定义”和内存分配在别处。
int func(int x); // 声明一个函数 func,它接受一个整数并返回一个整数。
// --- 定义 (Definitions) ---
int i = 10; // 定义一个名为 i 的整数,并为它分配内存,初始化为 10。
int func(int x) // 定义之前声明的函数 func。
{
return x + 1; // 提供具体的实现代码。
}对于初学者来说,最重要的准则是:当你创建一个变量并给它赋值时,你通常同时完成了声明和定义。 int score = 100; 这是一个定义,因为它为 score 分配了内存,它自然也充当了声明的角色。
创建你的变量:定义与初始化
在C++中,创建一个变量就是定义它。一个至关重要的好习惯是,在定义变量时立即对其进行初始化(赋予初始值)。否则,变量将包含一个随机的“垃圾值”,这会是程序错误的常见来源。
初始化的常用方法
复制初始化 (Copy Initialization) 这是最传统的形式,使用等号
=。cppint a = 5; std::string name = "Alice";直接初始化 (Direct Initialization) 使用圆括号
()。cppint b(5); std::string name("Bob");列表初始化 (List Initialization) / 统一初始化 (Uniform Initialization) 这是C++11引入的现代化、更推荐的方式,使用花括号
{}。cppint c{5}; std::string name{"Carol"}; // 也可以使用等号 int d = {5}; // 初始化为空值(零初始化) int e{}; // e 的值被初始化为 0
为什么推荐使用列表初始化 {}? 它更安全!它不允许“收缩转换”,即从一个大范围的类型转换为一个小范围的类型,从而避免潜在的数据丢失。
double pi = 3.14;
// int x = pi; // 复制初始化,编译通过但有警告,x 的值变为 3,小数部分丢失。
// int y(pi); // 直接初始化,同样有警告,y 的值也是 3。
// int z{pi}; // 列表初始化,编译器会直接报错!因为它阻止了可能导致数据丢失的转换。新手建议:养成使用 {} 进行初始化的习惯,它可以帮助你在编译阶段就发现潜在的错误。
让编译器做推断:auto 关键字
有时,一个变量的类型名称可能非常长且复杂。现代C++提供了 auto 关键字,让你不必显式写出类型,而是让编译器根据初始化表达式自动推断出正确的类型。
// 传统方式
int x = 1;
std::string message = "Some long text";
// 使用 auto
auto y = 1; // 编译器推断 y 是 int 类型
auto z = 3.14; // 编译器推断 z 是 double 类型
auto msg = "Some long text"; // 再次提醒:编译器推断 msg 是 const char* 类型, 而非 std::string
// auto 必须在声明时初始化,否则编译器无法推断
// auto myVar; // 错误!使用 auto 可以让代码更简洁,并且在类型改变时更易于维护。
创建常量:const 关键字
我们已经接触过 const。它用于声明一个常量,其值在初始化之后就不能再被修改。
const 变量必须在定义时进行初始化。
const int screenWidth = 1920;
const double PI = 3.14159;
// screenWidth = 1024; // 错误!不能修改 const 变量使用 const 是一种向编译器和代码阅读者传达“这个值不应被改变”意图的有效方式,能够极大地增强代码的健壮性和可读性。
控制“生命周期”与“可见性”:static 与 extern
static 和 extern 是存储类说明符,它们会改变变量的默认行为。
static 当 static 用于函数内部的局部变量时,它会改变该变量的“生命周期”。
- 普通局部变量:每次函数调用时创建,函数结束时销毁。
static局部变量:在程序第一次执行到其定义时被创建和初始化,并且在函数调用结束后不会被销毁。它的值会一直保留到下一次函数调用。
#include <iostream>
void counter() {
static int count = 0; // 只在第一次调用时初始化为 0
count++;
std::cout << "This function has been called " << count << " times." << std::endl;
}
int main() {
counter(); // 输出: ... 1 times.
counter(); // 输出: ... 2 times.
counter(); // 输出: ... 3 times.
return 0;
}externextern 关键字用于处理多文件项目。当你在一个 .cpp 文件中想使用另一个 .cpp 文件中定义的全局变量时,就需要使用 extern 来声明它。
extern 就像一个承诺:“编译器,请相信我,这个变量在别的文件里已经被定义了,你先通过编译,链接器会找到它的。”
// --- FileA.cpp ---
#include <iostream>
int global_var = 42; // 定义
// --- FileB.cpp ---
#include <iostream>
extern int global_var; // 声明 FileA 中定义的全局变量
void useGlobal() {
std::cout << global_var << std::endl; // 可以正常使用,输出 42
}给类型起个别名:using 关键字
当类型名称很长或很复杂时,我们可以使用 using 关键字为它创建一个更简单、更有意义的别名。
#include <string>
#include <vector>
// 为一个复杂类型创建一个别名
using NamesList = std::vector<std::string>;
void processNames(NamesList names) { // 使用别名,代码更清晰
// ...
}
int main() {
NamesList myNames = {"Alice", "Bob"};
processNames(myNames);
}注意:在老式C++代码中,你可能会看到 typedef 关键字做同样的事情。using 是C++11引入的更现代化、更灵活的替代方案。
让数据动起来 —— 运算符与表达式
我们已经创建了各种类型的“数据盒子”(变量),现在是时候学习如何操作这些盒子里面的东西了。运算符 (Operator) 就是C++中用于对数据(也称为操作数 (Operand))执行计算、比较或赋值等操作的特殊符号。
当运算符、字面量和变量组合在一起时,就构成了表达式 (Expression)。例如,x + 5 就是一个表达式,它的计算结果是一个值。
基础数学:算术运算符 (Arithmetic Operators)
这些是你最熟悉的运算符,就像计算器上的按钮一样。
| 运算符 | 名称 | 示例 | 结果 |
|---|---|---|---|
+ | 加法 | 10 + 5 | 15 |
- | 减法 | 10 - 5 | 5 |
* | 乘法 | 10 * 5 | 50 |
/ | 除法 | 10 / 5 | 2 |
% | 取模 (取余) | 10 % 3 | 1 |
#include <iostream>
int main() {
int a = 10;
int b = 3;
std::cout << "a + b = " << (a + b) << std::endl; // 输出 13
std::cout << "a - b = " << (a - b) << std::endl; // 输出 7
std::cout << "a * b = " << (a * b) << std::endl; // 输出 30
std::cout << "a / b = " << (a / b) << std::endl; // 输出 3 (整数除法,小数部分被舍弃)
std::cout << "a % b = " << (a % b) << std::endl; // 输出 1 (10除以3的余数是1)
}特别注意:
- 整数除法:当两个整数相除时,结果仍然是整数,小数部分会被直接舍弃,而不是四舍五入。例如,
5 / 2的结果是2。 - 取模运算符
%:它计算的是整数除法的余数。这个运算符在很多场景下都非常有用,比如判断一个数是奇数还是偶数 (number % 2 == 0)。
最常用的操作:赋值运算符 (Assignment Operators)
赋值运算符用于将一个值赋给一个变量。
基本赋值 (
=):int score = 100;复合赋值运算符: 它们是“操作和赋值”的简写形式。
| 运算符 | 示例 | 等价于 |
|---|---|---|
+= | x += 5 | x = x + 5 |
-= | x -= 5 | x = x - 5 |
*= | x *= 5 | x = x * 5 |
/= | x /= 5 | x = x / 5 |
%= | x %= 3 | x = x % 3 |
int bullets = 10;
bullets -= 3; // bullets 现在是 7在编程中,“加一”和“减一”是非常频繁的操作。
- 自增 (
++):将变量的值加 1。 - 自减 (
--):将变量的值减 1。
它们都有两种形式:
- 前缀 (Prefix):
++i。先改变变量的值,然后使用新值。 - 后缀 (Postfix):
i++。先使用变量的当前值,然后再改变变量的值。
int x = 5;
int y = 5;
// 前缀:先自增,再赋值
int result_x = ++x; // x 变成 6, 然后将 6 赋给 result_x
// 此处 x 是 6, result_x 是 6
// 后缀:先赋值,再自增
int result_y = y++; // 先将 5 赋给 result_y, 然后 y 变成 6
// 此处 y 是 6, result_y 是 5新手建议:为避免混淆,初期尽量将自增/自减操作单独写成一行 (x++;),而不是在复杂的表达式中使用。
判断真假:关系与比较运算符 (Relational and Comparison Operators)
这些运算符用于比较两个值,它们的结果永远是一个布尔值 (true 或 false)。
| 运算符 | 描述 | 示例 |
|---|---|---|
== | 等于 | a == b |
!= | 不等于 | a != b |
< | 小于 | a < b |
> | 大于 | a > b |
<= | 小于或等于 | a <= b |
>= | 大于或等于 | a >= b |
新手最易犯的错误:在需要判断“等于”时,误将 == 写成了 =。
if (score = 100): 这是一个赋值操作。它将100赋给score,并且整个表达式的值就是100。在C++中,任何非零的数值在if语句中都会被隐式转换为true,因此这个条件永远为真!这几乎肯定是一个逻辑错误。if (score == 100): 这才是正确的比较操作,判断score的值是否等于100。
组合判断:逻辑运算符 (Logical Operators)
逻辑运算符用于组合多个布尔表达式,形成更复杂的判断条件。
| 运算符 | 名称 | 描述 |
|---|---|---|
&& | 逻辑与 (AND) | 只有当两边的表达式都为 true 时,整个结果才为 true。 |
| ` | ` | |
! | 逻辑非 (NOT) | 将一个布尔值取反,true 变 false,false 变 true。 |
运算的顺序:优先级与结合性
当一个表达式中包含多个运算符时,由优先级 (Precedence) 和结合性 (Associativity) 决定计算顺序。
- 优先级:不同运算符之间的计算顺序。就像数学中先算乘除后算加减。
- 结合性:当多个具有相同优先级的运算符在一起时,它们的计算顺序(大部分是从左到右)。
最重要的规则:你不需要去死记硬背整个优先级表!当你不确定运算顺序时,或者想让代码的意图更清晰时,请毫不犹豫地使用圆括号 () 来强制指定运算顺序。
// 意图不明的代码
int result = a + b * c / d - e;
// 清晰的代码
int result = a + ((b * c) / d) - e;其他运算符一览
C++还有很多其他运算符,我们将在后续学习中遇到它们时再详细介绍:
- 成员访问运算符 (
.,->):用于访问类或结构体的成员。 - 范围解析运算符 (
::):用于指定命名空间,如std::cout。 - 条件运算符 (
? :):if-else的紧凑形式。 - 位运算符 (
&,|,^,~,<<,>>):用于对数据的二进制位进行底层操作。 sizeof运算符:用于获取一个类型或变量占用的内存大小。- 类型转换运算符:如
static_cast。
构建程序逻辑 —— 语句 (Statements)
如果说变量和运算符是程序的“名词”和“动词”,那么语句 (Statements) 就是将它们组织起来的“句子”。程序默认是顺序执行的,但语句可以让我们打破这个顺序,实现循环、判断等复杂的逻辑。C++中的语句种类繁多,但它们的核心目标都是控制程序的执行流程。
最常见的语句:表达式语句、空语句与声明语句
你其实已经写了很多语句了!
表达式语句 (Expression Statement):任何一个有效的表达式,只要在末尾加上一个分号
;,就构成了一个表达式语句。cppx = 5; // 赋值是一个表达式语句 balance += 10.0; // 复合赋值是一个表达式语句 x++; // 自增操作是一个表达式语句空语句 (Null Statement):它只由一个分号
;组成,表示“什么也不做”。虽然不常用,但在某些语法结构需要一个语句但我们又无事可做时,它就能派上用场。cpp; // 一个合法的C++语句,它什么都不做声明语句 (Declaration Statement):变量的声明和定义本身就是一种语句。
cppint score = 0; std::string playerName; const double PI = 3.14;
组织代码:复合语句 (Compound Statements / Blocks)
复合语句,也常被称为代码块 (Block),是由一对花括号 {} 包围起来的零个或多个语句的集合。
{
// 这是一个代码块
int local_var = 1;
local_var++;
}代码块最重要的特性是:在C++语法中,任何可以使用单个语句的地方,都可以使用一个代码块来代替。 这是后续学习 if, for, while 等控制语句的基础。
做出选择:选择语句 (Selection Statements)
选择语句让你的程序能够根据不同的条件执行不同的代码路径。
if 语句
if 语句用于执行一个测试。如果条件为 true,就执行紧随其后的语句或代码块。
if (score > 90)
{
std::cout << "优秀!" << std::endl;
}if-else 语句
if-else 提供了一个二选一的路径。如果条件为 true,执行 if 部分的代码;如果为 false,则执行 else 部分的代码。
if (temperature > 25)
{
std::cout << "天气炎热。" << std::endl;
}
else
{
std::cout << "天气凉爽。" << std::endl;
}if-else if-else 结构
当有多个互斥的条件需要判断时,可以使用 if-else if-else 链。
if (score >= 90) {
std::cout << "A" << std::endl;
} else if (score >= 80) {
std::cout << "B" << std::endl;
} else {
std::cout << "C" << std::endl;
}switch 语句
当你需要根据一个整数或字符类型变量的多个不同的常量值来执行不同操作时,switch 语句是比 if-else if 链更清晰的选择。
int choice = 2;
switch (choice)
{
case 1:
std::cout << "你选择了选项 1。" << std::endl;
break; // 非常重要!
case 2:
std::cout << "你选择了选项 2。" << std::endl;
break; // 阻止“穿透”到下一个 case
case 3:
std::cout << "你选择了选项 3。" << std::endl;
break;
default: // 如果没有任何 case 匹配
std::cout << "无效的选择。" << std::endl;
break;
}switch 的关键点:
case后面必须跟一个常量值。break语句至关重要! 如果省略了break,程序会继续执行下面所有的case,直到遇到break或switch结束。default是可选的,用于处理所有case都不匹配的情况。
重复执行:迭代语句 (Iteration Statements / Loops)
迭代语句,也就是循环,让程序可以重复执行一段代码,直到满足某个终止条件。
while 循环
while 循环在每次迭代之前检查条件。只要条件为 true,就不断执行循环体。
int countdown = 5;
while (countdown > 0)
{
std::cout << countdown << "..." << std::endl;
countdown--; // 必须在循环体内改变条件,否则会造成无限循环!
}
std::cout << "发射!" << std::endl;for 循环
for 循环是C++中最常用的循环结构,它将循环的初始化、条件判断和更新集中写在一起,结构更清晰。
// for (初始化; 条件; 更新)
for (int i = 1; i <= 5; i++)
{
std::cout << "这是第 " << i << " 次循环。" << std::endl;
}do-while 循环
do-while 循环在每次迭代之后检查条件。这意味着循环体至少会被执行一次。
char input;
do {
std::cout << "请输入 'Q' 退出: ";
std::cin >> input;
} while (input != 'Q' && input != 'q');改变路径:跳转语句 (Jump Statements)
跳转语句可以立即将程序的控制权转移到另一个地方。
return: 立即终止当前函数的执行,并将控制权交还给调用者。如果函数有返回类型,return语句后面需要跟一个返回值。cppint add(int a, int b) { return a + b; // 返回计算结果并结束函数 }break: 我们在switch中已经见过它。在循环中使用时,break会立即跳出并终止当前所在的循环。cppfor (int i = 0; i < 100; i++) { if (i == 10) { std::cout << "找到目标,停止搜索!" << std::endl; break; // 结束 for 循环 } }continue: 用于循环中,它会跳过当前这一次迭代的剩余部分,直接开始下一次迭代。cppfor (int i = 1; i <= 10; i++) { if (i % 2 != 0) { // 如果 i 是奇数 continue; // 跳过本次循环的 cout } std::cout << i << " 是一个偶数。" << std::endl; }
代码的组织者 —— 函数 (Functions)
到目前为止,我们所有的代码都写在了 main 函数里。随着程序变大,main 函数会变得越来越臃肿。函数 (Function) 就是解决这个问题的核心工具。
你可以把函数想象成一个有特定功能的“小机器”。你给它一些“原料”(参数),它会按照内部的“步骤”(函数体)进行处理,最后可能会给你一个“成品”(返回值)。
使用函数的好处:
- 重用性 (Reusability):一次编写,多次调用。
- 模块化 (Modularity):将复杂问题分解成一个个简单的小任务,让代码结构更清晰。
- 可维护性 (Maintainability):修改一个功能时,只需要在对应的函数内部修改。
函数的构成
一个函数主要由声明 (Declaration)和定义 (Definition)两部分组成。
函数声明 (Function Declaration),也叫函数原型 (Function Prototype): 它向编译器介绍函数的基本信息:返回什么类型的值、叫什么名字、需要哪些参数。声明以分号结尾。
cpp// 声明一个名为 sum 的函数 int sum(int a, int b);函数定义 (Function Definition): 它提供了函数的具体实现,即函数体
{}中的代码。cpp// 定义 sum 函数 int sum(int a, int b) // 函数头 (Header) { // 函数体 (Body) 开始 return a + b; // 函数体内容 } // 函数体 (Body) 结束
一个完整的函数由返回类型、函数名、参数列表和函数体构成。
声明、定义与调用
在C++中,一个函数在使用(调用)它之前,必须至少被声明过。编译器需要通过声明来检查你的调用是否正确。
#include <iostream>
// 1. 函数声明 (原型)
void greet(std::string name);
int main()
{
// 2. 函数调用
greet("Alice"); // 调用 greet 函数,并传入 "Alice" 作为参数
return 0;
}
// 3. 函数定义 (实现)
void greet(std::string name)
{
std::cout << "Hello, " << name << "!" << std::endl;
}当然,你也可以在使用之前直接定义函数,这样就不需要单独的声明了:
#include <iostream>
// 直接定义函数
void greet(std::string name)
{
std::cout << "Hello, " << name << "!" << std::endl;
}
int main()
{
greet("Bob");
return 0;
}传递数据:参数与实参
- 形参 (Parameter):在函数定义时写的变量,如
int a。 - 实参 (Argument):在函数调用时传递的真实值,如
sum(10, 5)中的10和5。
C++中主要的参数传递方式有两种:
按值传递 (Pass-by-Value) 这是默认的传递方式。函数得到的是实参的一个副本 (copy)。在函数内部对形参的任何修改,都不会影响到函数外部的原始实参。
void tryToChange(int x) {
x = 100; // 修改的是 x 的副本
}
int main() {
int myValue = 5;
tryToChange(myValue);
// myValue 的值仍然是 5
return 0;
}按引用传递 (Pass-by-Reference) 通过在参数类型后加上 & 符号,可以实现按引用传递。此时,函数得到的是原始实参的别名。在函数内部对形参的修改,将直接影响到原始实参。
void reallyChange(int& x) { // 注意这里的 &
x = 100; // 直接修改原始变量
}
int main() {
int myValue = 5;
reallyChange(myValue);
// myValue 的值变成了 100
return 0;
}何时使用按引用传递?
- 当你需要在函数内修改原始变量时。
- 当你传递的参数是大型对象(如
std::string)时,可以避免昂贵的复制操作,提高程序效率。
安全的引用传递:const 引用 如果你只是为了避免复制大型对象,但又不希望函数修改它,可以使用 const 引用。
// 既高效又安全,函数只能读取,不能修改
void printLargeString(const std::string& str) {
// str = "new value"; // 错误!不能修改 const 引用
std::cout << str << std::endl;
}返回数据:return 语句
return 语句有两个作用:
- 立即终止当前函数的执行。
- 向函数调用者返回一个值。
int add(int a, int b) {
return a + b; // 返回 a 和 b 的和
}
void checkAge(int age) {
if (age < 18) {
std::cout << "未成年" << std::endl;
return; // 条件满足,直接结束函数,后面的代码不执行
}
std::cout << "已成年" << std::endl;
}函数重载 (Function Overloading)
C++允许你定义多个同名的函数,只要它们的参数列表不同(参数的数量或类型不同)。这称为函数重载。编译器会根据你调用时提供的实参类型,自动选择匹配的函数版本。
#include <iostream>
#include <string>
// 重载的 print 函数
void print(int value) {
std::cout << "Integer: " << value << std::endl;
}
void print(double value) {
std::cout << "Double: " << value << std::endl;
}
void print(const std::string& value) {
std::cout << "String: " << value << std::endl;
}
int main() {
print(10); // 调用 print(int)
print(3.14); // 调用 print(double)
print("Hello"); // 调用 print(const std::string&)
return 0;
}创建你自己的类型 —— 类与结构 (Classes and Structs)
到目前为止,我们使用的所有类型,如 int, double, std::string,都是预先定义好的。如果我们想表示一个更复杂的概念,比如一个游戏中的“玩家”,该怎么办?
一个玩家可能拥有名字、生命值、分数等。我们需要一种方法,将所有与“玩家”相关的数据打包成一个独立的、完整的单元。这就是类 (Class) 和结构体 (Struct) 发挥作用的地方。
数据“打包盒”:struct 结构体
struct 允许我们将多个不同类型的变量组合在一起,形成一个新的、自定义的类型。
#include <iostream>
#include <string>
// 1. 定义一个名为 Player 的新类型
struct Player
{
// 这些是 Player 类型的 "数据成员 (Data Members)"
std::string name;
int health;
int score;
}; // 注意:定义末尾必须有分号 ;
int main()
{
// 2. 使用我们新定义的类型来创建变量 (称为“对象”或“实例”)
Player player1;
// 3. 使用点(.)运算符来访问对象的成员
player1.name = "Hero";
player1.health = 100;
// 也可以在创建时直接初始化
Player player2 = {"Villain", 120, 50};
// 打印玩家信息
std::cout << "Player 1 Name: " << player1.name << std::endl;
std::cout << "Player 2 Health: " << player2.health << std::endl;
return 0;
}通过 struct Player,我们成功地创建了一个全新的类型!现在,Player 就像 int 一样,可以用来创建变量。我们把通过一个类型创建出来的具体变量称为该类型的对象 (Object)。
更强大的“智能盒子”:class 类
class 是C++中实现面向对象编程的核心。它不仅能像 struct 一样打包数据,更重要的是,它还能将操作这些数据的函数也一并打包进去。
class 与 struct 的唯一区别 在C++中,class 和 struct 功能上几乎完全相同,唯一的区别在于默认的成员访问权限:
struct的成员默认是public(公开的),意味着在外部可以直接访问。class的成员默认是private(私有的),意味着在外部不能直接访问。
这个区别背后是面向对象编程的核心思想:封装 (Encapsulation)。封装就像是把数据锁在一个保险箱里,然后提供一套公开、安全的按钮(函数)来操作这些数据,从而保证数据的安全。
#include <iostream>
#include <string>
class Player
{
public: // 公开部分:外部可以访问的“按钮”
// 构造函数:一个特殊的成员函数,在创建对象时自动调用
Player(std::string n, int h) {
name = n;
health = h;
score = 0; // 初始分数为0
}
// 成员函数 (Member Function): 定义对象的行为
void takeDamage(int amount) {
if (amount > 0) {
health -= amount;
if (health < 0) {
health = 0;
}
}
}
void displayStatus() {
std::cout << "Name: " << name
<< ", Health: " << health << std::endl;
}
private: // 私有部分:被保护的内部数据
std::string name;
int health;
int score;
};
int main()
{
Player hero("Hero", 100);
// hero.health = 9999; // 错误!health 是 private 的,不能在外部直接访问
// 必须通过公开的成员函数来与对象交互
hero.takeDamage(25);
hero.displayStatus();
}在这个例子中:
- 数据成员
name,health,score被放在private:区域,它们被保护起来了。 - 我们提供了一系列
public:的成员函数 (Member Functions),如takeDamage和displayStatus,作为与外界交互的唯一接口。 - 我们还定义了一个特殊的构造函数 (Constructor)
Player(...),它确保了每个Player对象在被创建时都能被正确地初始化。
struct vs class:如何选择?
遵循一个普遍的编程约定:
- 当你只是想把一些数据打包在一起,并且这些数据不需要复杂的规则来保护时,使用
struct。它通常用于表示纯数据的集合。 - 当你创建一个更复杂的“对象”,它既有数据又有行为,并且你需要通过封装来保护其内部状态时,使用
class。这是实现面向对象设计的标准方式。
类的构成:成员详解
一个类或结构体由其成员 (Members) 组成,定义了类的状态 (State) 和行为 (Behavior)。成员主要分为数据成员(变量)和成员函数(函数)。
对象的“出生”与“消亡”:构造函数与析构函数
构造函数 (Constructor)
- 作用:在创建对象时自动被调用,用于初始化对象的数据成员。
- 特点:
- 函数名与类名完全相同。
- 没有返回类型(连
void都不写)。 - 可以被重载。
class Player
{
public:
// 构造函数
Player(std::string n, int h) {
name = n;
health = h;
}
private:
std::string name;
int health;
};
int main()
{
// 当这行代码执行时,Player的构造函数会被自动调用
Player hero("Hero", 100);
}默认构造函数 (Default Constructor):一个不带任何参数的构造函数。如果你没有定义任何构造函数,编译器会自动为你生成一个。但只要你定义了任何一个构造函数,编译器就不再自动生成了。
析构函数 (Destructor)
- 作用:在对象被销毁时(例如,函数结束时局部对象超出作用域)自动被调用,用于执行清理工作。
- 特点:
- 函数名是波浪号
~加上类名。 - 没有返回类型,也没有任何参数。
- 一个类最多只能有一个析构函数。
- 函数名是波浪号
#include <iostream>
class Message
{
public:
Message(std::string text) : content(text) {
std::cout << "Message created: " << content << std::endl;
}
~Message() { // 析构函数
std::cout << "Message destroyed: " << content << std::endl;
}
private:
std::string content;
};
int main()
{
{
Message msg("Hello"); // 对象在代码块内创建
} // 当代码块结束时,msg 超出作用域,其析构函数被调用
return 0;
}现代C++的推荐:成员初始化
在C++11及以后的版本中,我们可以在声明数据成员时直接为其提供一个默认的初始值。这是一种非常好的做法,可以防止忘记在构造函数中初始化某个成员。
class Player
{
private:
// 直接在声明时进行成员初始化
std::string name{ "DefaultPlayer" };
int health{ 100 };
int score{ 0 };
};此外,在构造函数中使用成员初始化列表比在函数体内赋值更高效。
class Player {
public:
// 使用成员初始化列表,更高效
// 它直接在成员创建时就用传入的值来初始化,
// 避免了先默认构造一个成员,再对其赋值的额外步骤。
Player(std::string n, int h) : name(n), health(h), score(0) {
// 构造函数体可以为空,或者执行其他逻辑
}
private:
std::string name;
int health;
int score;
};共享的数据:静态成员 (Static Members)
通常情况下,每个对象都有自己的一份独立的数据成员。但有时,我们希望某个数据成员被类的所有对象共享。这时就可以使用 static 关键字。
- 静态数据成员 (Static Data Member):它不属于任何单个对象,而是属于整个类。所有对象访问的都是同一个静态成员的副本。它必须在类的外部进行定义和初始化。
- 静态成员函数 (Static Member Function):它也属于整个类,不与任何特定对象关联。因此,它不能访问非静态的成员,但可以访问静态成员。
#include <iostream>
class Player
{
public:
Player() {
playerCount++; // 每创建一个新对象,共享的计数器加一
}
// 静态成员函数
static int getPlayerCount() {
return playerCount;
}
private:
// 静态数据成员声明
static int playerCount;
};
// 静态数据成员的定义和初始化 (必须在类外部)
int Player::playerCount = 0;
int main()
{
// 可以通过类名直接调用静态成员函数
std::cout << "Initial players: " << Player::getPlayerCount() << std::endl; // 输出 0
Player p1;
Player p2;
std::cout << "Current players: " << Player::getPlayerCount() << std::endl; // 输出 2
}让类型更好用 —— 运算符重载 (Operator Overloading)
我们已经知道如何创建一个自定义类型,比如 Complex (复数)。如果我们想计算两个复数的和,可能会写一个 add 函数:Complex c = a.add(b);。这完全可行,但不够直观。在数学中,我们很自然地会写 c = a + b。运算符重载就允许我们重新定义 + 等运算符,让它能够直接用于我们自己创建的对象。
什么是运算符重载?
运算符重载,本质上就是编写一个特殊的函数,这个函数的名字是 operator 关键字后跟你想重载的运算符符号。例如,重载 + 运算符的函数名就是 operator+。
当编译器看到一个表达式,比如 a + b,并且发现操作数是我们自定义的类类型时,它就会把这个表达式自动转换为对 operator+ 函数的调用。
如何实现运算符重载
重载运算符的函数可以作为类的成员函数,也可以作为全局函数。
作为成员函数重载
class Complex {
public:
Complex(double r, double i) : re(r), im(i) {}
// 重载 + 运算符作为成员函数
// 对于 a + b,'a' 是调用该函数的对象, 'b' 是传入的参数 'other'
Complex operator+(const Complex& other) {
return Complex(re + other.re, im + other.im);
}
// ...
private:
double re, im;
};
int main() {
Complex a(1.2, 3.4);
Complex b(5.6, 7.8);
// 这里看起来是简单的加法...
Complex c = a + b;
// ...但编译器在后台把它翻译成了: c = a.operator+(b);
}当运算符作为二元成员函数时,它只接受一个参数,代表运算符右边的操作数。运算符左边的操作数,就是调用这个函数的对象本身。
重载规则与限制
- 不能创建新运算符。
- 不能改变内置类型的行为(不能重载
int + int)。 - 不能改变运算符的优先级、结合性或操作数数量。
- 一些运算符不能被重载,如
.、::、?:、sizeof。
最佳实践:重载运算符时,应尽量遵循其原始的数学或逻辑含义,避免创造令人困惑的行为。
一个更常见的例子:重载 << 用于输出
我们希望也能直接用 std::cout 输出我们自己的 Complex 对象:std::cout << a;。
由于 std::cout 对象在 << 的左边,这种重载通常被实现为全局函数(通常是类的友元 friend 函数,以便访问私有成员)。
#include <iostream>
class Complex {
public:
Complex(double r, double i) : re(r), im(i) {}
// 将全局的 operator<< 声明为本类的友元
friend std::ostream& operator<<(std::ostream& os, const Complex& c);
private:
double re, im;
};
// 重载 << 作为全局函数
std::ostream& operator<<(std::ostream& os, const Complex& c)
{
os << c.re << " + " << c.im << "i";
return os; // 返回 ostream& 是为了支持链式调用,如 cout << a << b;
}
int main() {
Complex a(1.2, 3.4);
// 现在可以直接输出了!
std::cout << "a = " << a << std::endl; // 编译器翻译成 operator<<(std::cout, a);
}数据的集合 —— C风格数组 (C-Style Arrays)
如果我们需要存储一系列相同类型的数据,比如一个班级所有学生的50个考试成绩,就需要使用数组 (Array)。
重要提示:本章学习的是从C语言继承而来的C风格数组。它是C++的基础,但存在一些固有缺陷。在现代C++编程中,我们强烈推荐使用标准库提供的更安全、更强大的 std::vector 和 std::array。
什么是数组?
数组是一个由相同类型的元素组成的序列,这些元素在内存中占据一块连续的空间。你可以把它想象成一排连号的储物柜,通过柜子的编号(索引)来快速找到任何一个柜子。
声明和初始化数组
声明一个数组需要指定元素的类型、数组的名称和数组的大小。
// 语法: element_type array_name[array_size];
int scores[5]; // 声明一个可以存放 5 个整数的数组关键点:
- 数组的大小必须是一个在编译时就能确定的常量。
- 数组的索引 (index) 从 0 开始。对于大小为 5 的数组,有效索引是 0, 1, 2, 3, 4。
在定义数组时,最好立即对其进行初始化。最常用的方法是使用列表初始化 ({})。
// 1. 完全初始化
int scores[5]{95, 88, 76, 100, 92};
// 2. 部分初始化,其余元素会被自动初始化为 0
int results[10]{1, 1, 2}; // results 将是 {1, 1, 2, 0, 0, 0, 0, 0, 0, 0}
// 3. 根据初始值推断大小
int data[]{10, 20, 30, 40}; // 编译器会自动计算出数组大小为 4访问数组元素
我们使用下标运算符 ([]) 和索引来访问或修改数组中的特定元素。
int scores[5]{95, 88, 76, 100, 92};
// 访问第 0 个元素 (第一个元素)
int firstScore = scores[0]; // 95
// 修改第 2 个元素 (第三个元素)
scores[2] = 80;⚠️ 严重警告:数组越界! C++的C风格数组不会自动检查你的索引是否在有效范围内。如果你尝试访问一个不存在的索引,比如 scores[5],会导致未定义行为 (Undefined Behavior),可能会读取到随机数据,或破坏其他变量的值,导致程序崩溃。这是C风格数组最危险的地方之一。
数组与函数:指针衰变 (Pointer Decay)
当你将一个数组作为参数传递给一个函数时,数组会“衰变” (decay)成一个指向其第一个元素的指针。这意味着在函数内部,丢失了数组的大小信息。
因此,当你向函数传递一个C风格数组时,必须额外传递一个参数来告诉函数数组的大小。
#include <iostream>
#include <cstddef> // for size_t
// const double arr[]: 告诉阅读者这是一个数组,但本质上还是一个指针
void printArray(const double arr[], size_t size) {
for (size_t i = 0; i < size; ++i) {
std::cout << arr[i] << " ";
}
}
int main() {
double temperatures[]{25.5, 28.3, 22.1};
// 计算数组大小
size_t count = sizeof(temperatures) / sizeof(double); // (8 * 3) / 8 = 3
printArray(temperatures, count);
}从C++17标准开始,你可以使用更安全、更简洁的 std::size(arrayName) 函数来获取C风格数组的大小,这可以避免计算错误。
多维数组
数组的元素本身也可以是数组,这就构成了多维数组,可以把它想象成一个表格或矩阵。
// 声明一个 2 行 3 列的二维数组
int matrix[2][3] = {
{1, 2, 3}, // 第 0 行
{4, 5, 6} // 第 1 行
};
// 访问第 1 行,第 2 列的元素 (值为 6)
int element = matrix[1][2];现代C++的更好选择:std::vector 与 std::array
C风格数组功能有限且不安全。现代C++标准库提供了更好的替代品。
std::vector:动态大小的数组std::vector 是一个可以在运行时动态增长或缩小的数组。它会自动管理内存,你不需要关心 new 和 delete。
#include <vector>
#include <iostream>
int main() {
std::vector<int> scores; // 创建一个空的整数 vector
scores.push_back(95); // 添加元素
scores.push_back(88);
scores.push_back(76);
scores[1] = 90; // 像普通数组一样访问
// vector 知道自己的大小
for (size_t i = 0; i < scores.size(); ++i) {
std::cout << scores[i] << " ";
}
}std::array:固定大小的安全数组std::array 是对C风格数组的封装,它同样在编译时确定大小,但更安全。它不会自动衰变为指针,并且总是携带自己的大小信息。
#include <array>
#include <iostream>
void printArray(const std::array<int, 3>& arr) {
for (int element : arr) { // 使用更现代的范围 for 循环
std::cout << element << " ";
}
}
int main() {
std::array<int, 3> data{10, 20, 30};
printArray(data); // 无需额外传递大小
}新手建议:对于几乎所有需要数组的场景,都应该优先使用 std::vector。
深入内存的强大工具 —— 指针 (Pointers)
欢迎来到C++中最强大、最灵活,同时也最具挑战性的主题之一:指针。理解指针,意味着你开始真正理解计算机程序是如何与内存进行交互的。
什么是指针?
每个变量都被存储在内存中的某个位置,每个位置都有一个独一无二的地址。
指针 (Pointer) 就是一种特殊的变量,它的值不是普通的数据(如 10),而是另一个变量的内存地址。
指针的核心操作
操作指针主要涉及三个特殊运算符:
地址运算符 (
&): 获取一个变量的内存地址。读作“address of”。声明指针 (
*): 在声明变量时,在类型后面加上*,表示这是一个指针变量。解引用运算符 (
*): 放在一个已初始化的指针变量前,获取或修改指针所指向地址上的那个值。读作“value pointed to by”。
#include <iostream>
int main() {
int number = 42;
int* pNumber; // 声明一个整型指针
pNumber = &number; // 使用 '&' 获取 number 的地址,存入指针
std::cout << "Value of number: " << number << std::endl; // 输出 42
std::cout << "Value of pNumber: " << pNumber << std::endl; // 输出 number 的内存地址
std::cout << "Value pointed to by pNumber: " << *pNumber << std::endl; // 使用 '*' 解引用,输出 42
// 通过指针修改原始变量的值
*pNumber = 100;
std::cout << "New value of number: " << number << std::endl; // 输出 100
}空指针 nullptr 与未初始化指针的危险
空指针 (
nullptr): 一个指针如果不指向任何有效的内存地址,我们就称之为空指针。在现代C++中,我们使用nullptr关键字来表示它。在解引用一个指针之前,检查它是否为nullptr是一个非常好的习惯,可以避免程序崩溃。cppint* ptr = nullptr; if (ptr != nullptr) { // 只有在 ptr 不为空时,才进行解引用 *ptr = 5; }未初始化指针 (野指针): 这是极其危险的。一个只被声明但未被初始化的指针,它里面的地址是随机的。如果你试图解引用一个野指针,将导致未定义行为,通常是程序崩溃。
规则:永远不要解引用一个未初始化的指针。要么用
nullptr初始化它,要么让它指向一个有效的变量地址。
指针与数组的“亲密关系”
数组名在很多情况下会“衰变”成一个指向其首元素的指针。
int scores[5] = {10, 20, 30, 40, 50};
int* pScores = scores; // 数组名 scores 衰变成指向第一个元素的指针
// scores[2] 和 *(pScores + 2) 是等价的
std::cout << *(pScores + 2) << std::endl; // 输出 30指针算术 (Pointer Arithmetic):当你对一个指针进行加减运算时,它移动的单位是它所指向的数据类型的大小。pScores + 1 会让指针指向数组的下一个元素。
堆内存与 new/delete
我们创建的局部变量大多位于栈 (Stack)上,由编译器自动管理。但如果我们想创建一个生命周期不受作用域限制的大对象,就需要使用堆 (Heap)内存,但需要我们手动管理。
new运算符: 在堆上分配内存,并返回一个指向该内存的指针。delete运算符: 释放由new分配的内存。delete[]运算符: 释放由new[]分配的数组内存。
int* pHeapInt = new int(42);
double* pHeapArray = new double[100];
// ... 使用这些指针 ...
// 必须手动释放内存,否则会造成内存泄漏!
delete pHeapInt;
delete[] pHeapArray;手动内存管理极易出错,如忘记 delete(内存泄漏)、delete 两次(程序崩溃)等。
现代C++的救星:智能指针 (Smart Pointers)
智能指针是行为像指针的类对象。它们封装了一个原始指针,最关键的特性是:当一个智能指针对象被销毁时(例如超出作用域),它的析构函数会自动释放它所管理的内存(调用 delete)。这让我们几乎可以完全避免手动调用 delete!
std::unique_ptr (唯一指针)
- 表示对所管理资源的独占所有权。
- 不能被复制,只能被移动 (move)。这保证了在任何时候,只有一个指针指向同一个资源。
- 是管理动态内存的首选智能指针。
#include <memory>
#include <iostream>
void processData(std::unique_ptr<int> ptr) {
std::cout << "Processing data: " << *ptr << std::endl;
} // 当函数结束时,ptr 会自动被销毁,并释放它所管理的内存
int main() {
auto u_ptr = std::make_unique<int>(123); // 推荐的创建方式
// 将所有权“移动”给函数。
// 这行代码执行后,u_ptr 本身将变为空指针(nullptr),
// 它所管理内存的唯一所有权已经转移给了 processData 函数内的参数 ptr。
processData(std::move(u_ptr));
// if (u_ptr) { ... } // 这里的判断会是 false
return 0;
}std::shared_ptr (共享指针)
- 表示对资源的共享所有权,内部有一个引用计数器。
- 当最后一个指向资源的
shared_ptr被销毁时,资源才被释放。
新手建议:尽可能使用 std::unique_ptr。告别手动的 new 和 delete,让智能指针来为你管理内存。
处理程序中的意外 —— 异常处理 (Exception Handling)
程序运行时可能会遇到各种预料之外的“意外”情况:用户输入了无效的数据、要读取的文件不存在、网络连接中断等。这些情况被称为异常 (Exceptions)。
异常处理 (Exception Handling) 是一种机制,它允许我们以一种结构化的方式来应对这些运行时错误,从而避免程序突然崩溃。它能将错误处理代码与正常的业务逻辑代码分离开来,使代码更清晰。
异常处理的三大关键字:try, throw, catch
try(尝试):你把可能引发异常的代码放在try后面的代码块{}里。throw(抛出):当在try块中检测到一个错误时,使用throw关键字来“抛出”一个异常。catch(捕获):catch块紧跟在try块之后,用于处理被抛出的异常。你可以有多个catch块来处理不同类型的异常。
#include <iostream>
#include <stdexcept> // 标准异常类的头文件
// 一个可能抛出异常的函数
double divide(double numerator, double denominator)
{
if (denominator == 0) {
// 抛出一个标准库定义的运行时错误异常
throw std::runtime_error("Division by zero!");
}
return numerator / denominator;
}
int main()
{
try
{
double result = divide(10.0, 0.0);
std::cout << "Result: " << result << std::endl;
}
catch (const std::runtime_error& e) // 按 const 引用捕获
{
// e.what() 可以获取异常的描述信息
std::cerr << "Error occurred: " << e.what() << std::endl;
}
catch (...) // ... (三个点) 是一个万能捕获块,能捕获任何类型的异常
{
std::cerr << "Caught an unknown exception!" << std::endl;
}
std::cout << "Program continues execution." << std::endl;
}当 divide(10.0, 0.0) 执行到 throw 时,程序立即中断当前执行流程,开始寻找匹配的 catch 块。它找到了 catch (const std::runtime_error& e),于是执行该 catch 块内的代码。
异常处理的最佳实践
为“异常”情况使用异常:不要用异常来处理普通的、可预期的程序逻辑。异常应该用于处理真正的、意外的错误。
通过值抛出,通过 const 引用捕获:
throw std::runtime_error("Error!");(通过值创建并抛出一个临时对象)catch (const std::exception& e)(通过const引用捕获)。通过std::exception&这样的基类引用来捕获,利用了多态的特性,可以捕获到所有派生自std::exception的异常类型(如std::runtime_error),同时还避免了不必要的对象复制。
使用标准库异常:
<stdexcept>头文件为我们提供了一系列标准的异常类,如std::invalid_argument,std::out_of_range,std::runtime_error等。优先使用它们。异常安全 (Exception Safety) 与 RAII:当异常被抛出时,从
try块开始到异常抛出点之间,所有在栈上创建的局部对象都会被自动、正确地销毁(它们的析构函数会被调用)。这个过程被称为栈展开 (Stack Unwinding)。这就是为什么智能指针 (std::unique_ptr) 如此重要!如果你使用智能指针来管理内存,即使发生异常,智能指针对象也会在栈展开过程中被销毁,其析构函数会自动释放它所管理的内存,从而杜绝了内存泄漏。这个原则被称为 RAII (Resource Acquisition Is Initialization),是编写异常安全代码的基石。不要从析构函数中抛出异常:这是一个严格的规则,因为它可能导致程序在处理一个异常的过程中遇到第二个异常,从而直接终止。
构建类的层次结构 —— 继承 (Inheritance)
在面向对象编程中,继承 (Inheritance) 是用来表达“is-a” (是一种) 关系的机制。我们可以先定义一个通用的“基类”(比如 Animal),然后再创建更具体的“派生类”(比如 Dog, Cat),这些派生类会自动拥有基类的所有通用特性,并且可以添加自己独有的功能。
核心概念:基类与派生类
- 基类 (Base Class):被其他类继承的类。它代表了更通用的概念(也常被称为父类)。
- 派生类 (Derived Class):从其他类继承而来的类。它代表了更具体的概念(也常被称为子类)。
继承的好处:代码重用,并建立清晰的类型层次关系。
如何实现继承
在C++中,我们使用冒号 : 来表示继承关系。对于初学者,请始终使用 public 继承,它最符合直观的“is-a”关系。
语法:class DerivedClassName : public BaseClassName { ... };
#include <iostream>
// 1. 定义一个通用的“基类”:Enemy
class Enemy
{
public:
int health;
void attack() {
std::cout << "Enemy attacks!" << std::endl;
}
};
// 2. 定义一个“派生类”:Goblin,它 "is-a" Enemy
class Goblin : public Enemy
{
public:
// Goblin 添加了自己独有的数据成员
std::string weapon;
};
int main()
{
Goblin g;
// 访问从 Enemy 继承来的成员
g.health = 50;
// 访问 Goblin 自己独有的成员
g.weapon = "Club";
// 调用从 Enemy 继承来的函数
g.attack();
}在这个例子中,Goblin 类自动地、无偿地获得了 Enemy 类的 health 数据成员和 attack() 成员函数。
继承中的访问控制:public, protected, private
这里我们需要引入一个新的访问说明符:protected。
public: 成员可以被任何人访问。protected: 成员可以被类本身以及它的所有派生类访问,但不能被外部代码直接访问。private: 成员只能被定义它的那个类自己访问。即使是派生类也无法直接访问基类的private成员。
当一个类 Derived 公开继承 (public) 自 Base 时:
| Base 类的成员 | 在 Derived 类中变成... |
|---|---|
public | public |
protected | protected |
private | 不可访问 (Inaccessible) |
class Parent {
public:
int public_var;
protected:
int protected_var;
private:
int private_var;
};
class Child : public Parent {
public:
void accessMembers() {
public_var = 1; // OK!
protected_var = 2; // OK!
// private_var = 3; // 错误! 派生类不能访问基类的 private 成员
}
};构造函数与继承
构造函数和析构函数是不会被继承的。
当创建一个派生类对象时,程序必须先创建它内部包含的那个“基类部分”,这意味着基类的构造函数必须先被调用。如果基类的构造函数需要参数,派生类需要使用构造函数的成员初始化列表来传递。
#include <iostream>
class Enemy
{
public:
Enemy(int h) : health(h) {
std::cout << "An Enemy was created." << std::endl;
}
protected:
int health;
};
class Goblin : public Enemy
{
public:
// 使用初始化列表来调用基类 Enemy 的构造函数
Goblin(int h, std::string w) : Enemy(h), weapon(w) {
std::cout << "A Goblin was created." << std::endl;
}
private:
std::string weapon;
};
int main()
{
// 创建 Goblin 对象时,程序会先调用 Enemy(50) 构造函数,
// 然后再执行 Goblin 构造函数的函数体。
Goblin g(50, "Club");
return 0;
}销毁顺序:对象的销毁顺序与构造顺序完全相反。当 g 对象被销毁时,会先调用 Goblin 的析构函数,然后再调用 Enemy 的析构函数。