C++的第⼀个程序

C++兼容C语⾔绝⼤多数的语法,所以C语⾔实现的hello world依旧可以运⾏,C++中需要把定义⽂件代码后缀改为.cpp,vs编译器看到是.cpp就会调⽤C++编译器编译,linux下要⽤g++编译,不再是gcc

#include<iostream>
using namespace std;
int main()
{
cout << "hello world\n" << endl;
return 0;
}

一.C++ 关键字(C++98)

C++98 标准中共有 63 个关键字。其中一部分继承自 C 语言(32 个),另一部分是 C++ 新增的

C++98 关键字完整列表(63个)

分类 关键字
基础数据类型 voidcharintfloatdoubleboolwchar_t
类型修饰符 longshortsignedunsigned
存储类型 autoregisterstaticexternmutable
常量/易变 constvolatile
类与对象 classstructunionpublicprotectedprivatefriendthisvirtual
函数相关 inlineoperatorsizeof
命名空间与模板 namespaceusingtemplatetypename
循环控制 fordowhilebreakcontinuegotoreturn
条件分支 ifelseswitchcasedefaultenum
异常处理 trycatchthrow
动态内存 newdelete
类型转换 const_caststatic_castdynamic_castreinterpret_cast
布尔与类型 truefalsetypeidtypedef
其他 asmexplicitexport

二.命名空间

为什么需要命名空间?

在C/C++中,变量、函数和后⾯要学到的类都是⼤量存在的,这些变量、函数和类的名称将都存在于全局作⽤域中,可能会导致很多冲突。使⽤命名空间的⽬的是对标识符的名称进⾏本地化,以避免命名冲突或名字污染,namespace关键字的出现就是针对这种问题的。

namespace的定义

  • 定义命名空间,需要使⽤到namespace关键字,后⾯跟命名空间的名字,然后接⼀对{}即可,{}中即为命名空间的成员。命名空间中可以定义变量/函数/类型等。
  •  namespace本质是定义出⼀个域,这个域跟全局域各⾃独⽴,不同的域可以定义同名变量,所以下⾯的rand不在冲突了。
  •  C++中域有函数局部域,全局域,命名空间域,类域;域影响的是编译时语法查找⼀个变量/函数/类型出处(声明或定义)的逻辑,所有有了域隔离,名字冲突就解决了。局部域和全局域除了会影响编译查找逻辑,还会影响变量的⽣命周期,命名空间域和类域不影响变量⽣命周期。
  •  namespace只能定义在全局,当然他还可以嵌套定义。
  •  项⽬⼯程中多⽂件中定义的同名namespace会认为是⼀个namespace,不会冲突。
  •  C++标准库都放在⼀个叫std(standard)的命名空间中。

如果没有 stdlib.h 头文件,代码可以正常运行;但包含之后,因为 stdlib.h 库中有一个叫 rand 函数,全局变量 rand 会与其冲突。

#include <stdio.h>
#include <stdlib.h>
int rand = 10;
int main()
{
// 编译报错:error C2365: “rand”: 重定义;以前的定义是“函数”
printf("%d\n", rand);
return 0;
}
  • 在不同的作用域中,可以定义同名的变量。
  • 在同一作用域下,不能定义同名的变量。 
include <stdio.h>
#include <stdlib.h>
// 1. 正常的命名空间定义
namespace bit
{
// 命名空间中可以定义变量/函数/类型
    int rand = 10;
int Add(int left, int right)
{
    return left + right;
}
struct Node
    {
    struct Node* next;
    int val;
    };
}
int main()
{
// 这⾥默认是访问的是全局的rand函数指针
printf("%p\n", rand);
// 这⾥指定bit命名空间中的rand
printf("%d\n", bit::rand);
return 0;
}

(1)正常的命名空间定义 

namespace my_code
{
    int count = 100;                    // 命名空间中可以定义变量
    int Max(int x, int y)               // 命名空间中可以定义函数
    {
        return x > y ? x : y;
    }
}

my_code 是命名空间的名字,一般开发中是用项目名或模块名作为命名空间名。

(2) 命名空间可以嵌套

namespace A
{
    int a = 10;
    int b = 20;

    namespace B          // 嵌套命名空间
    {
        int c = 30;
        int Sub(int x, int y)
        {
            return x - y;
        }
    }
}

(3) 同一个工程中允许存在多个相同名称的命名空间

common.h
namespace Utils
{
    int Max(int x, int y)
    {
        return x > y ? x : y;
    }
}

common.cpp

namespace Utils
{
    int Min(int x, int y)
    {
        return x < y ? x : y;
    }
}

同一个工程中的 common.h 和 common.cpp 中的两个 Utils 会被编译器合并成同一个命名空间。

注意一个命名空间就定义了一个新的作用域,命名空间中的所有内容都局限于该命名空间中。

命名空间的使用

命名空间的使用有三种方式:

(1) 加命名空间名称及作用域限定符

int main()
{
    my_code::count = 200;
    int ret = my_code::Max(10, 20);
    return 0;
}

特点: 每次都要写 命名空间::,最安全,不会造成命名冲突。

(2) 使用 using 声明某个成员

using my_code::Max;

int main()
{
    int ret = Max(5, 15);    // 可以直接使用 Max
    // count 仍然需要加前缀
    my_code::count = 50;
    return 0;
}

特点: 只引入常用的几个成员,既方便又不会污染整个作用域。

(3) 使用 using namespace 引入整个命名空间

using namespace my_code;

int main()
{
    count = 300;             // 可以直接使用 count
    int ret = Max(8, 12);    // 可以直接使用 Max
    return 0;
}

特点: 最方便,但容易造成命名冲突,不推荐在头文件中使用

对比

方式 语法 安全性 便捷性 推荐场景
作用域限定符 命名空间::成员 最安全 较繁琐 所有场景,尤其大项目
using 声明 using 命名空间::成员 较安全 中等 频繁使用某几个成员时
using namespace using namespace 命名空间 有风险 最方便 小项目、cpp 文件内

补充:标准命名空间 std

C++ 标准库(如 iostreamvectoralgorithm 等)中的所有内容都定义在命名空间 std 中。所以在实际开发中,为了避免你自己写的变量 / 函数 / 类型等等与标准库中的冲突,建议不要把命名空间 std 直接展开到全局,而是把常用的展开就行,一般规范的写法如下:

#include<iostream>
//using namespace std;  //不要将std直接展开到全局
 
//展开常用的就行
using std::cout;
using std::endl;
 
int main()
{
	cout << "hello world" << endl;
	return 0;
}

日常小训练不用特别规范,可以全部展开用


三.输入和输出

  •  <iostream> 是 Input Output Stream 的缩写,是标准的输⼊、输出流库,定义了标准的输⼊、输出对象。
  •  std::cin 是 istream 类的对象,它主要⾯向窄字符(narrow characters (of type char))的标准输⼊流。
  • std::cout 是 ostream 类的对象,它主要⾯向窄字符的标准输出流。
  •  std::endl 是⼀个函数,流插⼊输出时,相当于插⼊⼀个换⾏字符加刷新缓冲区。
  •  <<是流插⼊运算符,>>是流提取运算符。(C语⾔还⽤这两个运算符做位运算左移/右移)
  •  使⽤C++输⼊输出更⽅便,不需要像printf/scanf输⼊输出时那样,需要⼿动指定格式,C++的输⼊输出可以⾃动识别变量类型本质是通过函数重载实现的,这个以后会讲到),其实最重要的是C++的流能更好的⽀持⾃定义类型对象的输⼊输出。)
  •  IO流涉及类和对象,运算符重载、继承等很多⾯向对象的知识,这些知识我们还没有讲解,所以这⾥我们只能简单认识⼀下C++ IO流的⽤法,后⾯我们会有专⻔的⼀个章节来细节IO流库。
  •  cout/cin/endl等都属于C++标准库,C++标准库都放在⼀个叫std(standard)的命名空间中,所以要通过命名空间的使⽤⽅式去⽤他们。
  •  ⼀般⽇常练习中我们可以using namespace std,实际项⽬开发中不建议using namespace
#include <iostream>
using namespace std;
int main()
{
int a = 0;
double b = 0.1;
char c = 'x';

cout << a << " " << b << " " << c << endl;
std::cout << a << " " << b << " " << c << std::endl;
scanf("%d%lf", &a, &b);
printf("%d %lf\n", a, b);
// 可以⾃动识别变量的类型
cin >> a;
cin >> b >> c;
cout << a << endl;
cout << b << " " << c << endl;
return 0;
}

关于 cout 和 cin 的一点补充

cout 和 cin 本身有很多高级用法,比如控制浮点数精度、设置整数输出进制等。不过 C++ 兼容 C 语言,这些格式化输出功能实际开发中用得并不多。对于浮点数输出,cout 默认只打印小数点后 5 位,如果你需要更精确的格式控制,直接用 printf 更方便。总之,灵活一点,哪个顺手用哪个。

std 命名空间怎么展开更合理?

std 是 C++ 标准库的命名空间,展开方式分场景:

日常学习时:直接 using namespace std 就行,省事,不影响学语法。

项目开发时:展开整个 std 可能会有风险——万一你自己写的函数名叫 max 或 swap,就和标准库冲突了。项目越大、人越多,这种冲突越容易出现。所以推荐下面两种写法:

  • 用的时候加前缀:std::cout

  • 或者只展开常用的几个:using std::cout; using std::endl;

#include <iostream>

using std::cout;
using std::endl;

int main()
{
    cout << "Hello" << endl;
    return 0;
}

头文件里:绝对不要写 using namespace std,否则包含这个头文件的所有文件都会被强行灌入整个标准库,很容易出问题。


四. 缺省参数

定义:

缺省参数是声明或定义函数时为函数的参数指定⼀个缺省值,调⽤该函数时,如果没有指定实参则采⽤该形参的缺省值,否则使⽤指定的实参

#include <iostream>
#include <assert.h>
using namespace std;
void Func(int a = 0)
{
cout << a << endl;
}
int main()
{
Func();
// 没有传参时,使⽤参数的默认值
Func(10);
// 传参时,使⽤指定的实参
return 0;
}

分类:

参数分为全缺省和半缺省参数。(有些地⽅把缺省参数也叫默认参数)

#include <iostream>
using namespace std;
// 全缺省
void Func1(int a = 10, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl << endl;
}
// 半缺省
void Func2(int a, int b = 10, int c = 20)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl << endl;
}
int main()
{
Func1();
Func1(1);
Func1(1,2);
Func1(1,2,3);
Func2(100);
Func2(100, 200);
Func2(100, 200, 300);
return 0;
}

缺省参数注意事项:

  1. 半缺省参数必须从右往左依次给出,不能间隔着给,否则编译器无法匹配实参。

  2. 缺省参数不能在函数声明和定义中同时出现,只能选择一处指定,通常写在声明中。

  3. 缺省值必须是常量或全局变量,不能是局部变量或非常量表达式。

  4. C 语言不支持缺省参数,这是 C++ 独有的特性。

// test.h —— 声明中写缺省值
void Func(int a = 10);

// test.cpp —— 定义中不写缺省值
void Func(int a)
{}
  • 缺省值只在声明中给出,定义中不再重复

  • 一般将缺省值写在头文件(.h)中,源文件(.cpp)只负责实现

原因说明: 头文件是给调用者看的,调用者需要知道缺省值是什么;源文件是内部实现,不需要也不应该重复写缺省值。


五.函数重载

C++⽀持在同⼀作⽤域中出现同名函数,但是要求这些同名函数的形参不同,可以是参数个数不同或者类型不同。这样C++函数调⽤就表现出了多态⾏为,使⽤更灵活。C语⾔是不⽀持同⼀作⽤域中出现同名函数的

定义:

  1. 同一作用域:重载的函数必须在同一个作用域内(如同一个类、同一个命名空间或全局作用域)。

  2. 函数名相同:多个函数使用相同的名称。

  3. 参数列表不同(至少满足其一):

    • 参数个数不同

    • 参数类型不同

    • 参数顺序不同

  4. 返回值类型可以相同也可以不同:返回值类型不参与重载判断,仅靠返回值不同不能构成重载。

 1、参数类型不同

#include<iostream>
using namespace std;
// 1、参数类型不同
int Add(int left, int right)
{
cout << "int Add(int left, int right)" << endl;
return left + right;
}
double Add(double left, double right)
{
cout << "double Add(double left, double right)" << endl;
return left + right;
}

2、参数个数不同

void f()
{
cout << "f()" << endl;
}
void f(int a)
{
cout << "f(int a)" << endl;
}

3、参数类型顺序不同


void f(int a, char b)
{
cout << "f(int a,char b)" << endl;
}
void f(char b, int a)
{
cout << "f(char b, int a)" << endl;
}

C++ 函数重载的原理——名字修饰(Name Mangling)

为什么 C++ 支持函数重载,而 C 语言不支持?

要回答这个问题,需要先了解程序从源码到运行的过程:预处理 → 编译 → 汇编 → 链接

在实际项目中,通常由多个头文件和源文件构成。假设在 test2.cpp 中调用了 test1.cpp 中定义的 Add 函数,在编译链接前,test2.o 目标文件中并没有 Add 函数的地址(因为 Add 的定义在 test1.cpp 中,地址在 test1.o 里)。链接器的任务就是解决这个问题:它发现 test2.o 调用了 Add 但没有地址,就会去 test1.o 的符号表中查找 Add 的地址,然后将两者链接到一起。

那么问题来了:链接器到底用什么名字去查找?

不同编译器有自己的名字修饰规则。Windows 下 VS 的修饰规则较复杂,而 Linux 下 GCC/G++ 的规则简单易懂。下面用 G++ 演示名字修饰后的结果。


二、C 语言编译器的名字修饰

采用 C 语言编译器(如 gcc)编译以下代码:

int Add(int a, int b)
{
    return a + b;
}

void Func(int a, double b, int* p)
{
}

int main()
{
    Add(1, 2);
    Func(1, 2, &p);
    return 0;
}

用 gcc 编译后,通过 objdump 查看符号表,函数名基本保持原样,不会附加参数信息。

这意味着:C 语言中,函数名就是唯一的标识符。如果定义了两个同名的函数,编译器无法区分,直接报错。


三、C++ 编译器的名字修饰

采用 C++ 编译器(如 g++)编译同样的代码,再查看符号表:

g++ -c test.cpp
objdump -t test.o

你会看到函数名被修饰成了类似这样的形式:

原函数 修饰后的名字
Add(int, int) _Z3Addii
Func(int, double, int*) _Z4FuncidPi

命名规则解读:

  • _Z:固定前缀,表示这是 C++ 修饰后的名字

  • 3 或 4:函数名的长度(Add 是 3,Func 是 4)

  • Add / Func:原函数名

  • idPi:参数类型编码(i = int,d = double,Pi = int*)

核心结论: C++ 编译器将函数名 + 参数类型组合编码,生成唯一的修饰名。参数类型不同,修饰名就不同。这就是 C++ 支持函数重载的根本原因。


四、名字修饰的本质

语言 函数标识方式 是否支持重载
C 只靠函数名 不支持
C++ 函数名 + 参数类型 支持

名字修饰的本质: 编译器把函数的返回类型、参数类型、参数个数等信息编码进函数名中,生成一个“内部名字”。链接器看到的不是 Add,而是 _Z3Addii 这样的字符串。

因此,重载的 Add(int, int) 和 Add(double, double) 在链接器眼中是完全不同的两个符号,不会产生冲突。


五、为什么返回值类型不参与重载?

因为函数调用时,编译器需要根据实参来匹配对应的函数。返回值类型在调用时无法作为匹配依据(调用者不一定会用返回值)。所以 C++ 规定返回值类型不同不能构成重载,名字修饰时也不编码返回值信息。

总结:

C++ 通过名字修饰(Name Mangling)把函数参数类型编码进函数名,使得同名但参数不同的函数在链接器眼中成为不同的符号,从而支持函数重载;而 C 语言不进行名字修饰,函数名就是唯一标识,所以不支持重载。


extern "C" 的作用

extern "C" 告诉 C++ 编译器:被修饰的函数按照 C 语言的方式编译和链接,不要进行 C++ 的名字修饰。

​
// 告诉编译器,这个函数按照 C 语言的方式处理
extern "C" {
    void Func(int a);
    int Add(int x, int y);
}

​

或者单个声明:

extern "C" void Func(int a);

三、常见使用场景

在 C++ 代码中调用 C 库函数时,通常这样写:

#ifdef __cplusplus
extern "C" {
#endif

#include "c_library.h"   // 头文件中的函数按 C 方式处理

#ifdef __cplusplus
}
#endif
  • __cplusplus 是 C++ 编译器自动定义的宏,C 编译器不会定义

  • 这样 C++ 编译时自动加上 extern "C",C 编译时则正常处理


六.引⽤(重点)

引⽤的概念和定义

引⽤不是新定义⼀个变量,⽽是给已存在变量取了⼀个别名,编译器不会为引⽤变量开辟内存空间,它和它引⽤的变量共⽤同⼀块内存空间。⽐如:⽔壶传中李逵,宋江叫"铁⽜",江湖上⼈称"⿊旋⻛";林冲,外号豹⼦头;

类型& 引⽤别名 = 引⽤对象;
C++中为了避免引⼊太多的运算符,会复⽤C语⾔的⼀些符号,⽐如前⾯的<< 和 >>,这⾥引⽤也和取地址使⽤了同⼀个符号&,⼤家注意使⽤⽅法⻆度区分就可以。(吐槽⼀下,这个问题其实挺坑的,个⼈觉得⽤更多符号反⽽更好,不容易混淆)

#include<iostream>
using namespace std;
int main()
{
int a = 0;
// 引⽤:b和c是a的别名
int& b = a;
int& c = a;
// 也可以给别名b取别名,d相当于还是a的别名
int& d = b;
++d;
// 这⾥取地址我们看到是⼀样的
cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
cout << &d << endl;
return 0;
}

运行结果:

注意:引用类型必须和引用实体同种类型的。

引⽤的特性

  •  引⽤在定义时必须初始化
  •  ⼀个变量可以有多个引⽤
  •  引⽤⼀旦引⽤⼀个实体,再不能引⽤其他实体

引⽤在定义时必须初始化

int& b;  // error

⼀个变量可以有多个引⽤

int a = 100;
int& b = a;
int& c = a;

引用一旦引用一个实体,就再不能引用其他的实体。

int main()
{    
    int a = 10;
    int& b = a;
 
    int c = 20;
    b = c; // error
    
    return 0;
}

引⽤的使⽤

  •  引⽤在实践中主要是于引⽤传参和引⽤做返回值中减少拷⻉提⾼效率和改变引⽤对象时同时改变被引⽤对象。
  •  引⽤传参跟指针传参功能是类似的,引⽤传参相对更⽅便⼀些。
  •  引⽤返回值的场景相对⽐较复杂,我们在这⾥简单讲了⼀下场景,还有⼀些内容后续类和对象章节中会继续深⼊讲解。
  • 引⽤和指针在实践中相辅相成,功能有重叠性,但是各有特点,互相不可替代。C++的引⽤跟其他语⾔的引⽤(如Java)是有很⼤的区别的,除了⽤法,最⼤的点,C++引⽤定义后不能改变指向,Java的引⽤可以改变指向

const 常引用:

  • 可以引⽤⼀个const对象,但是必须⽤const引⽤。const引⽤也可以引⽤普通对象,因为对象的访问权限在引⽤过程中可以缩⼩,但是不能放⼤。
  •  不需要注意的是类似 int& rb = a*3; double d = 12.34; int& rd = d; 这样⼀些场景下a*3的和结果保存在⼀个临时对象中, int& rd = d 也是类似,在类型转换中会产⽣临时对象存储中间值,也就是时,rb和rd引⽤的都是临时对象⽽C++规定临时对象具有常性,所以这⾥就触发了权限放⼤,必须要⽤常引⽤才可以。
  •  所谓临时对象就是编译器需要⼀个空间暂存表达式的求值结果时临时创建的⼀个未命名的对象,C++中把这个未命名对象叫做临时对象。

const 修饰的原理

const 的本质是缩小了原有的读写权限,将变量从可读可写变为只读

void test()
{
    // 1. 普通变量 → 普通引用(权限不变)
    int a = 10;
    int& ra = a;        // 可读可写,权限不变
    
    // 2. const 变量 → 普通引用(放大权限,错误)
    const int ca = 10;
    // int& rca = ca;   //  编译错误:ca 只读,rca 可读可写,放大了权限
    
    // 3. const 变量 → const 引用(权限不变)
    const int& rca = ca; //  只读,权限不变
    
    // 4. 字面常量 → 普通引用(放大权限,错误)
    // int& rb = 10;     //  编译错误:10 是常量,不能绑定到普通引用
    const int& rb = 10;  // 常引用可以绑定到常量
    
    // 5. 类型不同(普通引用)
    double d = 12.34;
    // int& rd = d;      //  编译错误:类型不同,且无法隐式转换(权限冲突)
    
    // 6. 类型不同(const 引用)
    const int& rd = d;   //  可以:d 先隐式转换为 int(产生临时变量),
                         //    临时变量具有常性,必须用 const 引用绑定
}

类型转换与 const 引用

1.理解临时变量

当不同类型之间发生隐式类型转换时,编译器会生成一个临时变量来存储转换后的值。

int i = 10;
double d = i;   // i 先转换为 double 类型,产生一个临时变量,再赋值给 d

2.const 引用与临时变量

int i = 10;
const double& r = i;   //  正确

详细过程:

  1. i 是 int 类型,r 是 double 类型的引用

  2. 类型不匹配,编译器将 i 隐式转换为 double

  3. 转换过程产生一个临时变量double 类型)

  4. r 绑定到这个临时变量,而不是直接绑定到 i

关键点: 临时变量具有常性,只能用 const 引用绑定,不能用普通引用。

// int& r = i;      //  错误:类型不匹配
// double& r = i;   //  错误:不能绑定临时变量(临时变量是常性的)
const double& r = i; //  正确:const 引用可以绑定临时变量

const 引用的好处

好处 说明
减少拷贝 引用传参避免复制大对象,提高效率
保护实参 const 防止函数内部意外修改传入的参数
兼容性强 既能传普通对象,也能传 const 对象
支持临时变量 可以接收字面常量、表达式结果、类型转换产生的临时变量

总结

原变量 引用类型 是否允许 原因
普通变量 普通引用 权限不变(读写 → 读写)
普通变量 const 引用 权限缩小(读写 → 只读)
const 变量 普通引用 权限放大(只读 → 读写)
const 变量 const 引用 权限不变(只读 → 只读)
字面常量 普通引用 常量不能绑定普通引用
字面常量 const 引用 常引用可以绑定常量
类型不同 普通引用 类型不匹配且隐式转换产生临时变量(常性)
类型不同 const 引用 const 引用可以绑定临时变量

总结

引用只能保持或缩小原有变量的读写权限,不能放大。const 引用可以绑定常量、临时变量以及进行类型转换后的结果,而普通引用不行。

const 的常见使用

场景 作用
const int a = 10; 定义常量,不可修改
const int* p; 指针指向的内容不可修改
int* const p; 指针本身不可修改(指向固定)
const int* const p; 指针和指向的内容都不可修改
const &a 常引用,延长临时变量生命周期,防止修改
const 成员函数 不修改对象成员,可被常量对象调用

隐式类型转换与临时变量

1.函数传参的最佳实践

  1. 函数传参时,如果想减少拷贝开销,应使用引用传参。如果函数内部不修改这个参数,最好使用 const 引用

  2. const 引用的好处是:保护实参,避免被函数误改;同时它可以接收普通对象,也可以接收 const 对象,兼容性更好。


2.隐式类型转换也会产生临时变量

类型转换不仅发生在赋值和引用绑定中,也发生在比较运算中。

int main()
{
    char ch = 0xff;   // char 范围一般是 -128~127,0xff 实际是 -1
    int j = 0xff;     // 0xff = 255

    if (ch == j)      // ch 被整型提升为 int,产生一个临时变量,值为 -1
    {
        cout << "相同" << endl;
    }
    else
    {
        cout << "不相同" << endl;   // -1 != 255,输出不相同
    }
    return 0;
}

详细过程:

  1. ch 是 char 类型,j 是 int 类型,类型不匹配

  2. 比较前,ch 被整型提升为 int

  3. 提升过程产生一个临时变量(值为 ch 转换后的 int 值)

  4. 用这个临时变量与 j 比较

注意: char ch = 0xff 在大多数编译器中,0xff(255)超出了 char 的范围(-128~127),实际被解释为 -1,而 j = 0xff = 255,所以 -1 != 255,输出“不相同”。


3.临时变量的常见产生场景

场景 说明
赋值时的类型转换 double d = i;
引用绑定时类型不匹配 const double& r = i;
比较运算时类型不匹配 ch == j
函数传参时类型不匹配 void func(double d); func(i);
表达式运算 int a = b + c; 中的中间结果

使用场景

1)做函数参数

a. 形参变量的改变,要影响到实参,用指针或引用解决

场景一:交换两数

// 引用写法
void Swap(int& ra, int& rb)
{
    int tmp = ra;
    ra = rb;
    rb = tmp;
}

int main()
{
    int a = 3, b = 5;
    Swap(a, b);   // 传变量名即可,简洁
    return 0;
}

对比指针: 引用写法更简洁,不需要取地址符 &,也不需要解引用 *


场景二:函数需要返回多个结果

// 分别求两个整数的商和余数
void Div(int a, int b, int& quotient, int& remainder)
{
    quotient = a / b;
    remainder = a % b;
}

int main()
{
    int q = 0, r = 0;
    Div(10, 3, q, r);
    // q = 3, r = 1
    return 0;
}

场景三:避免拷贝,提高效率

// 大对象传参,用 const 引用,避免拷贝
void Print(const vector<int>& vec)
{
    for (auto x : vec)
        cout << x << " ";
}

注意: 如果不需要修改实参,用 const 引用。

总结:

引用做参数的核心使用场景是:形参的改变需要影响实参。如交换两数、函数返回多个结果。若不需要修改实参,则用 const 引用,避免拷贝。

b. 引用在顺序栈中应用,函数传参时使用 const 引用主要有两个目的:

  1. 减少拷贝,提高效率:栈结构可能存储大量数据,传值会导致不必要的拷贝开销

  2. 保护实参不被修改:对于只读操作(如获取栈顶元素、判断栈空等),形参不应影响实参

// 获取栈顶元素(只读操作)
int GetTop(const Stack& st)   // const 引用,避免拷贝,且不会修改实参
{
    if (st.top == -1)
        return -1;
    return st.data[st.top];
}

// 判断栈是否为空
bool IsEmpty(const Stack& st) // const 引用
{
    return st.top == -1;
}

// 入栈(需要修改实参)
void Push(Stack& st, int val) // 普通引用,需要修改
{
    st.data[++st.top] = val;
}

c.const 引用的第二个好处:既可以接收变量,也可以接收常量

普通引用不能接收常量,但 const 引用可以。

cpp

void Print1(int& x)           // 普通引用
{
    cout << x << endl;
}

void Print2(const int& x)     // const 引用
{
    cout << x << endl;
}

int main()
{
    int a = 10;
    const int b = 20;
    
    // 普通引用
    Print1(a);     //  可以:接收变量
    // Print1(b);  //  错误:接收 const 变量,权限放大
    // Print1(30); //  错误:不能接收常量
    
    // const 引用
    Print2(a);     //  可以:接收普通变量
    Print2(b);     //  可以:接收 const 变量
    Print2(30);    //  可以:接收字面常量
    
    return 0;
}

引用做参数的完整总结

  1. 影响实参:通过形参的改变直接影响实参,替代指针的写法。

  2. 减少拷贝:传引用避免了对实参的拷贝,当参数占用内存较大时,可显著提高效率。

  3. 保护实参:函数内部不修改形参时,使用 const 引用,防止意外修改。

  4. 接收范围广const 引用既能接收普通变量和 const 变量,也能接收字面常量和临时对象。

  5. 延长临时对象生命周期const 引用绑定的临时对象,生命周期会延长至引用本身的作用域结束。

2)做函数返回值

函数运行时,系统需要给该函数开辟独立的栈空间,用来保存该函数的形参,局部变量以及一些寄存器信息等。
函数运行结束后,该函数对应的栈空间就被系统回收了。
空间被回收指该块栈空间暂时不能使用,但是内存本身还在。 


注意 :
如果函数返回时,出了函数的作用域,如果返回对象还在(还没还给系统),则可以使用 引用返回 ;
如果已经还给系统了,则必须使用 传值返回 。

引用做函数返回值的注意事项

一.错误用法:返回局部变量的引用

函数内部定义的局部变量在函数结束后会被销毁,其占用的内存空间会被回收。如果返回这个局部变量的引用,那么调用方拿到的是一个指向“已释放内存”的引用,这种写法是未定义行为,结果不可预测。

int& Add(int a, int b)
{
    int c = a + b;
    return c;   // 错误:c 是局部变量,函数结束即销毁
}

二.正确用法:返回生命周期比函数长的变量

以下几种情况可以安全地返回引用:

  1. 返回静态局部变量:静态变量在程序运行期间一直存在

  2. 返回全局变量:全局变量的生命周期贯穿整个程序

  3. 返回成员变量(类的成员函数中):成员变量的生命周期与对象一致

  4. 返回通过参数传入的引用:引用指向的对象由调用方管理

int& ReturnStatic(int a, int b)
{
    static int c;   // 静态变量,生命周期贯穿整个程序
    c = a + b;
    return c;       // 正确
}

int global = 0;
int& ReturnGlobal()
{
    global = 100;
    return global;  // 正确
}

三.返回引用的好处

好处 说明
避免拷贝 返回大对象时,返回引用比返回值效率更高
支持链式操作 如 cout << a << b,赋值运算符返回引用支持连续赋值
int& GetValue(int& x)
{
    x = x * 2;
    return x;   // 返回传入的引用,安全且可链式操作
}

int main()
{
    int a = 5;
    GetValue(a) = 100;   // 可以直接赋值
    // a 变为 100
    return 0;
}

传值、传引用比较

1)传值、传引用的效率比较

#include <iostream>
#include <time.h>
using namespace std;

struct A
{
    int a[10000]; // 4万字节的空间
};

// 传值,会拷贝4万字节的空间
void TestFunc1(A a)
{
}

// 传引用
void TestFunc2(A& a)
{
}

void TestRefAndValue()
{
    A a;
    // 1、以值作为函数参数
    size_t begin1 = clock();
    for (size_t i = 0; i < 10000; ++i)
    {
        TestFunc1(a);
    }
    size_t end1 = clock();

    // 2、以引用作为函数参数
    size_t begin2 = clock();
    for (size_t i = 0; i < 10000; ++i)
    {
        TestFunc2(a);
    }
    size_t end2 = clock();

    // 分别计算两个函数运行结束后的时间:
    cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;
    cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}

int main()
{
    TestRefAndValue();
    return 0;
}

以值作为参数或返回值类型时,函数在传参和返回过程中不会直接操作实参或变量本身,而是传递一份临时的拷贝。

这意味着:

  • 传参时,实参被拷贝一份交给形参

  • 返回时,局部变量被拷贝一份返回给调用方

当参数或返回值类型较大时(如包含大量数据的结构体、长字符串、容器等),拷贝操作的开销非常显著。每一次拷贝都需要分配内存、复制数据,多次拷贝会严重影响程序运行效率。

因此,对于较大的对象,应优先考虑使用引用或指针传递,以避免不必要的拷贝开销。

2)值返回和引用返回的性能比较

#include <iostream>
#include <time.h>
using namespace std;
 
struct B
{
    int b[10000]; // 4万字节的空间
};
 
B b;
 
// 值返回,会产生临时变量,且发生一次临时变量的拷贝
B TestFunc1()
{
    return b;
}
 
// 引用返回
B& TestFunc2()
{
    return b;
}
 
void TestReturnByRefOrValue()
{
    // 1、以值作为函数的返回值类型
    size_t begin1 = clock();
    for (size_t i = 0; i < 100000; ++i)
    {
        TestFunc1();
    }
    size_t end1 = clock();
 
    // 2、以引用作为函数的返回值类型
    size_t begin2 = clock();
    for (size_t i = 0; i < 100000; ++i)
    {
        TestFunc2();
    }
    size_t end2 = clock();
 
    // 计算两个函数运算完成之后的时间
    cout << "TestFunc1 time:" << end1 - begin1 << endl;
    cout << "TestFunc2 time:" << end2 - begin2 << endl;
}
 
int main()
{
	TestReturnByRefOrValue();
	return 0;
}

结论:发现传值和指针在作为传参以及返回值类型上效率相差很大,传值返回效率很低。

引用和指针的区别

语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。

引用的底层实现

在底层实现上,引用实际上是有空间的,因为引用是按照指针的方式来实现的。

所谓“底层实现”,指的是编译器将 C++ 的语法转换成汇编代码去执行的过程。通过反汇编(如 VS 调试模式下打开反汇编窗口)可以看到,引用的底层操作与指针完全一致:引用变量本身在栈上占用 4 或 8 字节(32 位或 64 位平台),存储的是被引用对象的地址;对引用进行读写操作时,实际也是通过该地址进行间接访问。

因此,引用在语法层面是“别名”,不占用空间;但在底层实现上,它和指针本质相同,占用空间,只是编译器对语法进行了封装,让开发者不用像使用指针那样显式地取地址和解引用。

int main()
{
    int c = 10;
    int& rc = c;
    rc = 20;
 
    int* pc = &c;
    *pc = 20;
 
    return 0;
}
汇编代码(反汇编结果)
assembly
int c = 10;
mov dword ptr [c], 0Ah          ; 将 10 存入 c

int& rc = c;
lea eax, [c]                    ; 取 c 的地址存入 eax
mov dword ptr [rc], eax         ; 将地址存入 rc(rc 占用栈空间)

rc = 20;
mov eax, dword ptr [rc]         ; 取出 rc 中保存的地址
mov dword ptr [eax], 14h        ; 将 20 存入该地址指向的内存

int* pc = &c;
lea eax, [c]                    ; 取 c 的地址存入 eax
mov dword ptr [pc], eax         ; 将地址存入 pc

*pc = 20;
mov eax, dword ptr [pc]         ; 取出 pc 中保存的地址
mov dword ptr [eax], 14h        ; 将 20 存入该地址指向的内存

引用和指针的不同点

一、概念不同

引用是变量的别名,不独立存在;指针存储的是变量的地址,本身是一个独立变量。

二、初始化要求不同

引用定义时必须初始化,必须明确绑定到哪个变量;指针可以不初始化,但未初始化的指针是野指针,存在风险。

三、指向能否改变不同

引用一旦绑定一个实体后,不能再改为引用其他实体;指针可以随时指向任意同类型的变量。

四、空值情况不同

不存在空引用,引用必须绑定有效变量;存在空指针,指针可以被赋值为 nullptr 或 NULL

五、sizeof 结果不同

对引用取 sizeof 得到的是引用类型本身的大小(如 int& 得 4 字节);对指针取 sizeof 得到的是指针变量本身的大小(32 位平台 4 字节,64 位平台 8 字节)。

六、自增运算含义不同

引用自增是让其绑定的实体值加 1;指针自增是让指针向后偏移一个类型的大小,指向下一个元素。

七、多级支持不同

存在多级指针(如 int**),不存在多级引用。int&& 是右值引用,不是多级引用。

八、访问方式不同

指针需要显式解引用(*p)才能访问目标变量;引用的解引用由编译器自动处理,使用者无需关心。

九、安全性不同

引用更安全:必须初始化、不能为空、不能改变指向。指针风险更高:可能出现野指针、空指针解引用、越界访问等问题。

 指针和引⽤的关系

C++中指针和引⽤就像两个性格迥异的亲兄弟,指针是哥哥,引⽤是弟弟,在实践中他们相辅相成,功能有重叠性,但是各有⾃⼰的特点,互相不可替代。

  •  语法概念上引⽤是⼀个变量的取别名不开空间,指针是存储⼀个变量地址,要开空间。
  •  引⽤在定义时必须初始化,指针建议初始化,但是语法上不是必须的。
  •  引⽤在初始化时引⽤⼀个对象后,就不能再引⽤其他对象;⽽指针可以在不断地改变指向对象。
  •  引⽤可以直接访问指向对象,指针需要解引⽤才是访问指向对象。
  •  sizeof中含义不同,引⽤结果为引⽤类型的⼤⼩,但指针始终是地址空间所占字节个数(32位平台下占4个字节,64位下是8byte)
  •  指针很容易出现空指针和野指针的问题,引⽤很少出现,引⽤使⽤起来相对更安全⼀些。

七、内联函数(inline)

⽤inline修饰的函数叫做内联函数,编译时C++编译器会在调⽤的地⽅展开内联函数,这样调⽤内联函数就需要建⽴栈帧了,就可以提⾼效率。

  •  inline对于编译器⽽⾔只是⼀个建议,也就是说,你加了inline编译器也可以选择在调⽤的地⽅不展开,不同编译器关于inline什么情况展开各不相同,因为C++标准没有规定这个。inline适⽤于频繁调⽤的短⼩函数,对于递归函数,代码相对多⼀些的函数,加上inline也会被编译器忽略。
  •  C语⾔实现宏函数也会在预处理时替换展开,但是宏函数实现很复杂很容易出错的,且不⽅便调试,C++设计了inline⽬的就是替代C的宏函数。
  • vs编译器 debug版本下⾯默认是不展开inline的,这样⽅便调试,debug版本想展开需要设置⼀下以下两个地⽅。
  •  inline不建议声明和定义分离到两个⽂件,分离会导致链接错误。因为inline被展开,就没有函数地址,链接时会出现报错。

源代码

int Add(int left, int right)
{
    return left + right;
}

int main()
{
    int ret = 0;
    ret = Add(1, 2);
    return 0;
}

汇编代码(普通函数调用)

int ret = 0;
mov    dword ptr [ret], 0

ret = Add(1, 2);
push    2          ; 压入第二个参数
push    1          ; 压入第一个参数
call    Add        ; 调用 Add 函数
add     esp, 8     ; 平衡栈
mov     dword ptr [ret], eax  ; 将返回值存入 ret

核心说明

普通函数调用:编译后生成 call 指令,程序执行时需要跳转到函数地址执行,函数执行完再返回,存在函数调用开销(压参、跳转、返回、栈平衡)。

内联函数:如果在 Add 函数前增加 inline 关键字,编译器在编译期间会用函数体直接替换函数调用,不再生成 call 指令。

inline int Add(int left, int right)
{
    return left + right;
}

内联后的汇编效果(逻辑示意):

int ret = 0;
mov    dword ptr [ret], 0

; ret = Add(1, 2); 的等价展开
mov    eax, 1
add    eax, 2
mov    dword ptr [ret], eax

内联函数的特点

特点 说明
调用方式 编译期展开函数体,不生成 call 指令
效率 消除函数调用开销,执行更快
代码体积 多次调用会导致代码膨胀
适用场景 函数体小、调用频繁的函数
局限性 inline 只是建议,编译器可以忽略

内联函数的特性

一、空间换时间

内联函数是一种以空间换时间的优化手段。如果编译器将函数作为内联函数处理,会在编译阶段直接用函数体替换函数调用。这样做的好处是减少了函数调用的开销(压参、跳转、栈平衡、返回等),提高了程序运行效率;缺点是多处调用会导致代码重复展开,目标文件体积变大。

二、inline 只是建议

inline 关键字对于编译器来说只是一个建议,并非强制要求。不同编译器的内联实现机制可能不同。通常建议:将函数体规模较小、非递归、且调用频繁的函数用 inline 修饰。如果函数体过大或存在递归,编译器会忽略 inline 请求,将其作为普通函数处理。

三、声明与定义不要分离

内联函数不建议将声明和定义分离到不同文件。因为内联函数在调用点直接展开,不会生成函数地址,链接器无法找到函数实现。正确的做法是将内联函数的定义直接放在头文件中。

查看方式:
在 release 模式下,查看编译器生成的汇编代码中是否存在 call Add。
在 debug 模式下,需要对编译器进行设置,否则不会展开(因为在 debug 模式下,编译器默认不会对代码进行优化,下面给出 VS2022的设置方式)。

// F.h
#include <iostream>
using namespace std;
inline void f(int i);
// F.cpp
#include "F.h"
void f(int i)
{
cout << i << endl;
}
// main.cpp
#include "F.h"
int main()
{
// 链接错误:⽆法解析的外部符号 "void __cdecl f(int)" (?f@@YAXH@Z)
f(10);
return 0;
}

回顾宏的知识点:

#include<iostream>
using namespace std;
// 实现⼀个ADD宏函数的常⻅问题
//#define ADD(int a, int b) return a + b;
//#define ADD(a, b) a + b;
//#define ADD(a, b) (a + b)
// 正确的宏实现
#define ADD(a, b) ((a) + (b))
// 为什么不能加分号?
// 为什么要加外⾯的括号?
// 为什么要加⾥⾯的括号?
int main()
{
int ret = ADD(1, 2);
cout << ADD(1, 2) << endl;
cout << ADD(1, 2)*5 << endl;
int x = 1, y = 2;
ADD(x & y, x | y); // -> (x&y+x|y)
return 0;
}

宏定义的正确写法

结论: #define ADD(a, b) ((a) + (b))

三个关键点:

  1. 整个表达式加外层括号:防止宏展开后被外部运算符干扰优先级

  2. 每个参数加内层括号:防止参数本身是表达式时运算符优先级出错

  3. 宏末尾不能加分号:宏是文本替换,分号应该由调用者决定

不加括号的后果:

写法 调用 ADD(1,2)*5 结果
a + b 1 + 2 * 5 11 (错误)
(a + b) (1 + 2) * 5 15 (正确)
参数不加括号 (x & y + x | y) 错误 
参数加括号 ((a)+(b)) ((x & y)+(x | y)) 正确 

总结: 宏定义中,每个参数加括号整个表达式加括号末尾不加分号


宏的优缺点

优点

  1. 增强代码复用性:通过宏定义常量或代码片段,可以在多处使用,避免重复编写。

  2. 提高性能:宏在预处理阶段进行文本替换,不产生函数调用开销,执行效率高。

缺点

  1. 不方便调试:宏在预处理阶段已经被替换,调试时看到的代码与源码不一致,无法在宏内部设置断点。

  2. 可读性差、可维护性差、容易误用:宏只是简单的文本替换,缺乏语法检查,容易产生意料之外的错误(如运算符优先级问题、参数多次求值等)。

  3. 没有类型安全检查:宏不检查参数类型,任何类型都可以传入,可能导致潜在的错误。


C++ 替代宏的技术

宏的用途 C++ 替代方案
常量定义 constconstexprenum
短小函数 inline 内联函数
类型别名 using(C++11)或 typedef
条件编译 仍可使用宏(预处理层面无法替代)

八.指针空值 nullptr(C++11)

NULL实际是⼀个宏,在传统的C头⽂件(stddef.h)中,可以看到如下代码:

#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endi
  • C++中NULL可能被定义为字⾯常量0,或者C中被定义为⽆类型指针(void*)的常量。不论采取何种定义,在使⽤空值的指针时,都不可避免的会遇到⼀些⿇烦,本想通过f(NULL)调⽤指针版本的f(int*)函数,但是由于NULL被定义成0,调⽤了f(int x),因此与程序的初衷相悖。f((void*)NULL);调⽤会报错。
  • C++11中引⼊nullptr,nullptr是⼀个特殊的关键字,nullptr是⼀种特殊类型的字⾯量,它可以转换成任意其他类型的指针类型。使⽤nullptr定义空指针可以避免类型转换的问题,因为nullptr只能被隐式地转换为指针类型,⽽不能被转换为整数类型。
#include<iostream>
using namespace std;
void f(int x)
{
cout << "f(int x)" << endl;
}
void f(int* ptr)
{
cout << "f(int* ptr)" << endl;
}
int main()
{
f(0);
// 本想通过f(NULL)调⽤指针版本的f(int*)函数,但是由于NULL被定义成0,调⽤了f(int
x),因此与程序的初衷相悖。
f(NULL);
f((int*)NULL);
// 编译报错:error C2665: “f”: 2 个重载中没有⼀个可以转换所有参数类型
// f((void*)NULL);
f(nullptr);
return 0;
}

结果:

f(int x)
f(int x)
f(int* ptr)

程序的本意是通过 f(NULL) 调用指针版本的 f(int*) 函数,但由于 NULL 在 C++98 中被定义为 0,编译器默认将 0 视为整型常量,导致实际调用的是整型版本的 f(int),与程序初衷相悖。

在 C++98 中,字面常量 0 既可以表示整数 0,也可以表示空指针((void*)0),但编译器默认将其解释为整型常量。若要将其作为指针使用,必须显式进行强制类型转换,如 (void*)0 或 (int*)NULL

调用 输出 原因
f(0) f(int x) 0 是整数,匹配 int 版本
f(NULL) f(int x) NULL 通常被定义为 0,仍匹配 int 版本
f((int*)NULL) f(int* ptr) 强制转换为 int*,匹配指针版本
f((void*)NULL) 编译报错 无法确定调用哪个重载(int 或 int*
f(nullptr) f(int* ptr) nullptr 是空指针常量,只能匹配指针版本

C++11 中的指针空值

在 C++11 中,引入了 nullptr 作为专门的指针空值关键字。使用 nullptr 不需要包含任何头文件,因为它本身就是 C++11 标准中的关键字。

sizeof(nullptr) 与 sizeof((void*)0) 的字节数相同,说明 nullptr 本质上是作为指针类型处理的,而不是整数。

为了提高代码的健壮性和可读性后续在表示指针空值时,建议优先使用 nullptr,而不是 NULL 或 0。这样可以避免因 NULL 被定义为 0 而导致的函数重载二义性问题,也能让代码意图更加清晰。


各位老铁,本节博客内容就到这,更多精彩等待后续更新,我们下节见~

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐