【C++】C++ 的入门知识2

发布于:2025-07-24 ⋅ 阅读:(14) ⋅ 点赞:(0)

本篇文章主要讲解 C++ 的入门语法知识引用、inline 关键字与 nullptr 关键字。


目录

1  引用

1) 引用的概念与定义

(1) 引用的概念        

(2) 引用的定义

2) 引用的特性 

3) 引用的使用场景以及作用

(1) 引用传参提高效率

(2) 改变引用对象同时改变被引用对象

(3) 引用做返回值

4) const 引用

5) 指针与引用的关系与区别

2  inline关键字

1) inline 关键字的用法

2) 内联函数的特性

3) 内联函数声明与定义分离到两个文件

(1) 报错原因

(2) 解决方法

3  nullptr 关键字


1  引用

1) 引用的概念与定义

        在C语言中,我们写一个交换函数我们需要使用指针类型作为形参,实现形参的改变影响实参:

#include<stdio.h>

void swap(int* x, int* y)
{
    int tmp = *x;
    *x = *y;
    *y = tmp;
}

而在C++中,我们可以这样写交换函数:

#include<iostream>

using namepsace std;

void swap(int& x, int& y)
{
    int tmp = x;
    x = y;
    y = tmp;
}

且结合上一篇文章我们讲解过的函数重载,可以定义出不同的交换函数:

#include<iostream>

using namespace std;

//交换整型
void swap(int& x, int& y)
{
    int tmp = x;
    x = y;
    y = tmp;
}

//交换浮点数
void swap(double& x, double& y)
{
    double tmp = x;
    x = y;
    y = tmp;
}


int main()
{
    int a = 10, b = 20;
    double c = 1.1, d = 2.2;
    cout << "交换前:";
    cout << a << ' ' << b << ' ' << c << ' ' << d << endl;

    swap(a, b);
    swap(c, d);
    
    cout << "交换后:"; 
    cout << a << ' ' << b << ' ' << c << ' ' << d << endl;

    return 0;
}

运行结果:

输出:
交换前:10 20 1.1 2.2
交换后:20 10 2.2 1.1

其中 int& 就称之为引用,在这里引用起着类似于指针的作用,接下来我们就来讲解引用。

(1) 引用的概念        

        引用并不是像指针一样重新定义一个变量,而是为已经存在的变量取一个别名,在语法层编译器并不会为其开辟一块空间(但底层依然使用指针),而是让它和它引用的变量共用一块空间。比如,西游记中孙悟空也叫做齐天大圣,虽然名字不同,但本质上都是指的同一个人;在C++中,被引用的变量就像孙悟空,引用就像齐天大圣,尽管名字不同,但本质上都是指向同一块空间

(2) 引用的定义

        在上面的交换函数中,我们已经见过引用是如何定义了,以下是定义引用的通用语法:

类型& 引用别名 = 被引用对象;

这里的类型可以是内置类型int, double等,也可以是后面STL(数据结构与算法库)中的自定义类型 vector<int>,string等。

        引用的语法符号为&,与给变量取地址是一个符号,在初学的时候容易搞混,只需要记住在变量(对象)前面的就是取地址,在类型后面就是引用即可。

#include<iostream>

using namespace std;

int main()
{
    int a = 10;
    //b是a的别名
    int& b = a;
    //取别名之后依旧可以取别名
    int& c = a;
    //也可给别名取别名
    int& d = b;
    //别名++,其他的别名与原名都++,因为都是一块空间
    ++d;
    cout << a << ' ' << b << ' ' << c << ' ' << d << endl;
    
    //原名++,其余的别名都++
    ++a;
    cout << a << ' ' << b << ' ' << c << ' ' << d << endl;

    //取地址都是相同的
    cout << &a << endl;
    cout << &b << endl;
    cout << &c << endl;
    cout << &d << endl;

    return 0;
}

 运行结果:

输出:
11 11 11 11
12 12 12 12
0000002A5CEFF724
0000002A5CEFF724
0000002A5CEFF724
0000002A5CEFF724

2) 引用的特性 

引用具有以下特性:

(1) 引用在定义时必须初始化

(2) 一个变量可以有多个引用

(3) 引用一旦引用一个实体,无法再引用其他实体

        其实引用的第一点特性是依赖于第三点特性的,由于引用在引用后无法改变,所以如果在定义时不初始化,后续又无法改变,因而是无法知道引用哪个实体的。

#include<iostream>

using namespace std;

int main()
{
    //编译报错,引用定义时必须初始化
    //int& a;

    int a = 10;
    int& b = a;
    cout << "改变前:";
    cout << a << ' ' << b << endl;
    int c = 20;
    //这里并不是改变引用对象,而是将c的值赋给a和b
    b = c;
    cout << "改变后:";
    cout << a << ' ' << b << endl;

    cout << &a << endl;
    cout << &b << endl;
    cout << &c << endl;

    return 0;
}

运行结果:

输出:
改变前:10 10
改变后:20 20
0000002030CFF974
0000002030CFF974
0000002030CFF9B4

3) 引用的使用场景以及作用

        引用的在实践中主要用于引用传参或者引用做返回值提高效率,以及改变引用对象的同时改变被引用对象(如上面的swap函数)。

(1) 引用传参提高效率

        在C语言中传参分为两种,一种是传值传参,一种是传址传参,传值传参会传递实参的拷贝,而传址传参只会传递实参的地址,所以传址传参是比传值传参效率更高的。引用传参就类似于传址传参(因为引用传参底层也是传递的地址),所以引用传参也是比传值传参效率更高的,但是引用传参比传址传参更加方便,因为传参的时候不需要再取地址,直接传递变量(对象)就可以。以下是传值传参,传址传参与引用传参的对比:

#include<string>

using namespace std;

//传值传参,x是a的拷贝
void Func1(int x)
{
    ++x;
}

//传址传参,x是a的地址
void Func2(int* x)
{
    ++(*x);
}

//引用传参,x是a的别名
void Func3(int& x)
{
    ++x;
}


int main()
{
    int a = 10;
    //传值传参,不改变实参
    Func1(a);
    cout << a << endl;

    //传址传参,需要取地址
    Func2(&a);
    cout << a << endl;

    //引用传参,不需要取地址,更加方便
    Func3(a);
    cout << a << endl;

    return 0;
}

 运行结果:

输出:
10
11
12

(2) 改变引用对象同时改变被引用对象

        在(1)引用提高传参效率的示例代码以及上面的 swap 函数中都多次使用了改变引用对象的同时改变被引用对象,所以这里就不再举例。总之,如果采用引用传参,在函数中,只要改变形参就可以改变实参。

(3) 引用做返回值

引用做返回值的场景相对复杂,所以我们结合以下的例子来进行讲解。

#include<iostream>
#include<assert.h>

using namespace std;

typedef int STDataType;
typedef struct Stack
{
    STDataType* arr;
    int top;
    int capacity;
}ST;

//初始化
//这里引用传参,改变rs会同时改变main函数中的st
void STInit(ST& rs, int n = 4)
{
    rs.arr = (STDataType*)malloc(sizeof(STDataType) * n);
    if (rs.arr == NULL)
    {
        perror("malloc fail!\n");
        exit(1);
    }

    rs.top = 0;
    rs.capacity = n;
}

//入栈
void STPush(ST& rs, STDataType x)
{
    //满了先扩容
    if (rs.top == rs.capacity)
    {
        int newCapacity = rs.capacity == 0 ? 4 : rs.capacity * 2;
        STDataType* tmp = (STDataType*)realloc(rs.arr, sizeof(STDataType) * newCapacity);
        if (tmp == NULL)
        {
            perror("realloc fail!\n");
            exit(1);
        }
        rs.arr = tmp;
        rs.capacity = newCapacity;
    }
    
    rs.arr[rs.top++] = x;
}

//取栈顶元素 -- 版本1
int STTop1(ST& rs)
{
    assert(rs.top > 0);

    int top = rs.arr[rs.top - 1];
    return top;
}

//取栈顶元素 -- 版本2
int& STTop2(ST& rs)
{
    assert(rs.top > 0);

    return rs.arr[rs.top - 1];
}

int main()
{
    ST st;
    //这里也不需要传地址了,因为是引用传参
    STInit(st);
    STPush(st, 1);
    STPush(st, 2);
    STPush(st, 3);
    STPush(st, 4);

    cout << STTop1(st) << endl;
    cout << STTop2(st) << endl;

    STTop2(st) = 5;
    cout << STTop1(st) << endl;
    return 0;
}

        上述代码实现了栈的一部分功能,包括初始化,入栈以及返回栈顶元。可以看到 STTop 实现了两个版本,一个是 int 作为返回值的版本,一个是 int& 作为返回值的版本,接下来我们来讲解这两个版本的 STTop 的区别。

STTop1:

上图中标识了 main 函数的函数栈帧中各变量以及 STTop 函数的函数栈帧中各变量在内存中的存储情况。main函数中有一个 st 的结构体变量,结构体中有三个变量,分别是 arr,top,以及capacity,都存储在 main 函数的栈帧里面,arr 指向了堆上的一块空间,里面存储了1,2,3,4 四个数据;在 STTop1的函数栈帧中存在一个 top 变量,该变量是 arr 数组中 st.top - 1 位置的拷贝,当STTop1 函数调用结束之后,函数栈帧被销毁,top 也就随之被销毁了,所以返回的其实是 top 的拷贝。

        经过上述分析,其实 STTop1 是不能用引用做为返回值的,因为如果用引用做返回值的话,返回的其实是 top 的那一块空间的引用,但是 top 的存储空间会随着 STTop1 函数栈帧的销毁而销毁,所以返回 top 空间的引用其实是野引用,访问的话会造成非法访问的,所以不能引用返回。

STTop2:

在 STTop2 函数栈帧中返回值是 rs.arr[rs.top - 1],而 rs 是 st 的别名,所以返回的其实是 st.arr[st.top - 1] 的引用,而 st.arr[st.top - 1] 的空间在 STTop2 函数的函数栈帧销毁后,st.arr[st.top - 1] 的空间是没有销毁的,所以是可以返回引用的,而且由于返回的是其引用,所以改变其返回值,原空间中的值也会进而发生改变。

示例代码的运行结果:

输出:
4
4
5

        总结来说,就是如果当前函数栈帧销毁后,返回值的空间没有被销毁,那就可以引用返回;如何当前函数栈帧销毁后,返回值的空间被销毁,那就不能引用返回,只能传值返回。而且引用返回还有一个好处,就是改变返回值,原值也会发生改变。


4) const 引用

        如果要引用一个对象,需要注意权限的放大与缩小问题,如果要引用一个 const 对象的话,如果使用普通引用,就涉及到了权限的放大问题,因为被引用的对象是 const 对象,是无法被改变的,具有常性,而如果使用普通引用,普通引用是可以改变被引用对象的值的,所以使用 const 引用引用普通对象就涉及到了权限放大问题(原对象不能改变,而引用对象可以改变),所以必须使用 const 引用。

#include<iostream>

using namespace std;

int main()
{
    const int a = 10;
   
    //这里不能使用普通引用,因为权限会放大
    //int& ra = a;
    //必须使用 const 引用
    const int& ra = a;

    //const引用不可改变
    //编译报错
    ++ra;

    return 0;
}

但是引用对象的权限是可以缩小的,所以一个普通对象是可以使用 const 引用的。

#include<iostream>

using namespace std;

int main()
{
    int a = 10;
    //可以使用 const 引用普通对象,权限的缩小
    const int& ra = a;

    //const 引用普通对象,同样不可以改变
    //编译报错
    ++ra;

    return 0;
}

还有一种情况涉及到权限的放大与缩小的问题:

#include<iostream>

using namespace std;

int main()
{
    int a = 10;
    //算数表达式的结果会存放在临时对象里面
    //临时对象具有常性,普通引用会权限放大
    //编译报错
    int& ra = a * 3;
    
    int b = 20;
    //类型转换的结果也会存放在临时对象里面
    double& rd = a;
    
    //字面量也具有常性,所以也会权限放大
    int& ri = 10;

    return 0;
}
    

当引用的对象具有常性时,如:临时对象(类型转换与运算表达式的值都会存放在临时对象里面)、匿名对象(后面会讲解)与字面量都具有常性,引用这些对象的时候需要使用 const 引用。 


5) 指针与引用的关系与区别

        通过上述对引用的讲解,可以发现其实引用与指针有许多功能是重叠,但是指针与引用在实践中是相辅相成,但又互相不可替代的。

指针与引用的区别:

(1) 引用在定义时必须初始化;虽然指针在定义时不必初始化,但是建议初始化

(2) 引用在语法层不开空间;但是指针是一个变量,要开空间

(3) 引用在引用一个对象后,不可更改引用的对象;但是指针在定义之后,可以更改指向的对象

(4) 引用可以直接访问对象,而指针需要解引用才能访问对象

(5) sizeof 中含义不同,sizeof 引用是引用对象的大小,而 sizeof 指针始终是 4 或者 8 个字节

        总结来说,虽然 C++ 引入了引用的新语法,但是引用是没法完全代替指针的,有些指针所能达到的效果是引用所达不到的,比如在结构体访问内部成员变量的时候,指针可以用 "->" 访问但是引用只能用 "." 访问,是不能同 "->" 访问的;同时引用的存在,在某些情况下又可以大大简化使用场景(如上面的swap函数),引用做返回值也是指针所达不到的。所以,指针与引用是相辅相成,互相不可替代的。


2  inline关键字

        在C语言中,我们学习过宏的概念,宏类似于函数,但是在使用宏的部分会直接替换,而不是去调用函数,比如定义一个乘法宏:

#include<iostream>

using namespace std;

#define MUL(X, Y) X * Y

int main()
{
    int c = MUL(2, 4);
    cout << c << endl;

    return 0;
}

运行结果:

输出:
8

但是写这样一个宏是有问题的,比如运行下面这个代码:

#include<iostream>

using namespace std;

#define MUL(X, Y) X * Y

int main()
{
    int c = MUL(2 + 4, 4 + 8);
    cout << c << endl;

    return 0;
}

运行结果:

输出:
26

很明显,我们想要输出的是72,但是输出的却是26,原因就是在预处理阶段代码被替换为了下面这段代码:

int main()
{
    int c = 2 + 4 * 4 + 8;
    cout << c << endl;

    return 0;
}

所以正确的乘法宏应该在参数和外面都加上括号:

#include<iostream>

using namespace std;

#define MUL(X, Y) ((X) * (Y))

int main()
{
    int c = MUL(2 + 4, 4 + 8);
    cout << c << endl;

    return 0;
}

运行结果:

输出:
72

        那么为什么不写成一个函数呢?是因为写为宏效率更高,写为宏在预处理阶段就直接替换了,在编译阶段就会将其变为指令,变为汇编;而函数还需要经历调用,开辟函数栈帧等一系列的过程,开销跟宏比起来大了很多;但是宏只适合于实现一些较为简单的功能,比如加法、减法、乘法等,而且特别容易写错,所以C++就综合了函数与宏的优点,将其合并为了 inline 关键字 -- 内联函数。


1) inline 关键字的用法

        inline 关键字的用法很简单,只要在函数定义的前面加 inline 关键字就可以了。如定义一个加法的内联函数:

#include<iostream>

using namespace std;

inline int Add(int x, int y)
{
    return x + y;
}

int main()
{
    int x, y;
    cin >> x >> y;
    
    cout << Add(x, y) << endl;

    return 0;
}

运行结果:

输入:2 10
输出:12

那么内联函数有没有直接展开呢?我们可以切换到反汇编看一下。

vs的 debug 下默认是不展开内联函数的,需要设置一下才能看到内联函数的展开:
第一步:先点击 vs 界面窗口中的项目选项卡,选择属性选项

第二步:点击常规选项,将调试信息格式改为程序数据库

 第三步:再点击优化选项,将内联函数扩展改为只适用于 _inline(/Ob1)

经过上述三个步骤,就可以在调试的反汇编界面看到内联函数的展开过程了。 

没有加 inline 关键字:

可以看到没有加 inline 关键字是调用了 Add 函数的,调用 Add 函数需要经历开辟函数栈帧,初始化等一系列过程,开销较大。再看加了 inline 关键字:

可以看到汇编代码中没有调用 Add 函数了,而是替换为了 add,mov 等一系列的指令,也就没有开辟函数栈帧等一系列的过程了,开销是比直接调用小的多的。


2) 内联函数的特性

        是不是所有的函数前面加上 inline 都会展开呢?并不是的,内联函数展不展开是取决于编译器的,当函数里的代码相对多一些、递归函数或者调用频繁的短小函数,编译器是不会选择展开函数的,因为如果每次调用都展开这个函数,是没有直接开辟函数栈帧的开销小的。比如一个函数其代码为 5 行,然后调用开辟函数栈帧等代码变为了 15 行。假设调用 100 次这个函数,如果展开代码会变为 500 行,但是调用仅仅需要 115 行(因为需要 call,所以会多 100 行),可以看到调用是比直接展开代码量少了 4/5 的,所以直接调用的开销更小。

#include<iostream>

using namespace std;

void Func1()
{
    int x = 0;
    x = 1;
    x = 2;
    x = 3;
    x = 4;
    x = 5;
    x = 6;
    x = 7;
    x = 8;
    x = 9;
    x = 10;
    x = 11;
}

inline int Fact(int n)
{
    if (n == 0) return 1;

    return n * Fact(n - 1);
}

int main()
{
    //代码量过多,不展开
    Func1();
    //递归函数不展开
    Fact(5);

    return 0;
}

反汇编代码:

        inline 关键字结合了宏和函数的优点,当代码量很小时,会直接展开函数,提高效率,而当代码量很大或者递归函数时,编译器会识别这种情况,选择不展开函数。所以展不展开函数全部由编译器所决定,程序员只管在前面加上 inline 关键字,展不展开全权交给编译器就可以了(但还是建议能不加的地方就不加,不要一股脑在所有的函数前面都加上inline)。


3) 内联函数声明与定义分离到两个文件

(1) 报错原因

        如果用 inline 关键字定义一个函数的话,是不能将函数声明和定义分离到两个文件的,分离会导致链接错误。以下是分离到两个文件的示例:

//F.h
#include<iostream>

using namespace std;

inline void f(int x);

//F.cpp
#include"F.h"

void f(int x)
{
    cout << x << endl;
}


//test.cpp
#include"F.h"

int main()
{
    f(10);
    return 0;
}

如果将 f 内联函数的声明与定义分离在 F.h 和 F.cpp 文件,是会发生链接错误的:
         原因就是因为在预处理阶段会将包含的头文件展开,在编译阶段会将调用了但是不在本文件中的函数生成一个无效地址放进符号表,在链接阶段会将所有文件合并,用定义了该函数的其他文件中的有效地址来替换这个无效地址,进而可以达到调用其他文件的函数的效果。但是内联函数与其他函数不同的是,在编译阶段会将该函数展开,也就没有该函数的有效地址,无效地址无法替换,就会发生链接错误(如果无法理解的话,可以去回顾之前讲解的编译与链接文章)。

(2) 解决方法

        只要将内联函数的定义放在头文件就可以解决了:

//F.h
#include<iostream>

using namespace std;

inline void f(int x)
{
    cout << x << endl;
}

//test.cpp
#include "F.h"

int main()
{
    f(10);
    return 0;
}

        能够解决问题的原因就是在 main 函数中调用 f 函数,由于 f 函数是内联函数,在编译阶段会直接替换,而内联函数直接定义在了 F.h 头文件中,这样编译器在编译阶段就能够直接看到内联函数的定义,从而在编译阶段编译器就能够正确的进行调用函数代码的替换,所以就不会发生链接错误了。


3  nullptr 关键字

        C++ 11(2011年推出的C++标准)中引入了nullptr来替代原C语言中的NULL,原因就是之前C语言中的 NULL 在C++中使用会存在一些问题。以下是 NULL 的定义:

//如果没有定义过NULL
#ifndef NULL
    //如果定义过__cplusplus,代表在C++文件中
    #ifdef __cplusplus
        //NULL被定义为0
        #define NULL 0
    #else
        //在C语言中被定义为空指针的0
        #define NULL ((void*)0)
    #endif
#endif

 可以看到在C++ 中NULL被定义为了0,所以在下列代码中会调用错误:

#include<iostream>

using namespace std;

void Func(int x)
{
    cout << "Func(int x)" << endl;
}

void Func(int* x)
{
    cout << "Func(int* x)" << endl;
}

int main()
{
    Func(NULL);

    return 0;
}

运行结果:

输出:Func(int x)

        本来是想要使用 NULL 来调用void Func(int* x) 函数,但是由于 NULL 被定义为了0,所以调用了void Func(int x) 函数。C++为了解决这种问题,重新定义了一个关键字 nullptr,nullptr是一个字面量,可以隐式的转换成其他的指针类型,但是不能转换成整型。所以使用 nullptr 会调用正确:

#include<iostream>

using namespace std;

void Func(int x)
{
    cout << "Func(int x)" << endl;
}

void Func(int* x)
{
    cout << "Func(int* x)" << endl;
}

int main()
{
    Func(nullptr);

    return 0;
}

运行结果:

输出:Func(int* x)

总之,以后在C++中空指针就不要使用 NULL 了,建议使用 nullptr


网站公告

今日签到

点亮在社区的每一天
去签到