C++ 初涉:掌握 C++ 弥补 C 语言不足,让编程更高效!

C语言的第一个程序

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

cpp 复制代码
// test.cpp
 #include<stdio.h>
 int main()
 {
 printf("hello world\n");
 return 0;
 }

当然C++有⼀套⾃⼰的输⼊输出,严格说C++版本的helloworld应该是这样写的。

cpp 复制代码
// test.cpp
// 这⾥的std cout等看不懂没关系,下⾯我会依次讲解
 
#include<iostream>
 using namespace std;
int main()
 {
    cout << "hello world\n" << endl;
    
    return 0;
 }

命名空间

namespace的价值

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

c语⾔项⽬类似下⾯程序这样的命名冲突是普遍存在的问题,C++引⼊namespace就是为了更好的解决这样的问题

cpp 复制代码
 #include <stdio.h>                                                                                                                    
 #include <stdlib.h>
 int rand = 10;
 int main()
 {
    // 编译报错:error C2365: "rand": 重定义;以前的定义是"函数" 
    printf("%d\n", rand);
    return 0;
 }

全局变量rand与stdlib的rand函数存在命名冲突存在命名冲突导致编译报错

namespace的定义

  • 定义命名空间,需要使⽤到namespace关键字,后⾯跟命名空间的名字,然后接⼀对{}即可,{}中即为命名空间的成员。命名空间中可以定义变量/函数/类型等。
cpp 复制代码
// zzy是命名空间的名字,⼀般开发中是⽤项⽬名字做命名空间名。
namespace zzy    

{
    /
}
cpp 复制代码
namespace zzy
{
	// 命名空间中可以定义变量/函数/类型
	int rand = 10;

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

	struct Node
	{
		struct Node* next;
		int val;
	};
}
  • namespace本质是定义出⼀个域,这个域跟全局域各⾃独⽴,不同的域可以定义同名变量,所以下⾯的rand不在冲突了。
cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
namespace bit
{

int rand = 10;

}
int main()
{
// 这⾥默认是访问的是全局的rand函数指针
printf("%p\n", rand);
// 这⾥指定zzy命名空间中的rand
printf("%d\n", zzy::rand);  //::域作用限定符
return 0;
}
  • C++中域有函数局部域,全局域,命名空间域,类域;域影响的是编译时语法查找⼀个变量/函数/ 类型出处(声明或定义)的逻辑,有了域隔离,名字冲突就解决了。局部域和全局域除了会影响编译查找逻辑,还会影响变量的⽣命周期,命名空间域和类域不影响变量⽣命周期。

局部域 和全局域出了作用域就会销毁,命名空间域中的变量为全局变量,因名字隔离不会与全局域冲突

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int x = 0;

namespace zzy
{
	int x = 1;
}

void func()
{
	int x = 2;//局部域只能在当前局部访问
}

int main()
{
	int x = 3;

	printf("%d\n", x);//访问当前局部域
	printf("%d\n", zzy::x);//访问命名空间域
	printf("%d\n", ::x);//访问全局域

	return 0;
}
  • namespace只能定义在全局,当然他还可以嵌套定义。
cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

namespace 602
{
	// zzy
	namespace zzy
	{
		int rand = 1;
		int Add(int left, int right)
		{
			return left + right;
		}
	}
	// lkk
	namespace lkk
	{
		int rand = 2;
		int Add(int left, int right)
		{
			return (left + right) * 10;
		}
	}
}
int main()
{
	printf("%d\n", bit::zzy::rand);
	printf("%d\n", bit::lkk::rand);
	printf("%d\n", bit::zzy::Add(1, 2));
	printf("%d\n", bit::lkk::Add(1, 2));
	return 0;
}
  • 项⽬⼯程中多⽂件中定义的同名namespace会认为是⼀个namespace,不会冲突。
cpp 复制代码
// 多⽂件中可以定义同名namespace,他们会默认合并到⼀起,就像同⼀个namespace⼀样
// Stack.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
namespace bit
{
	typedef int STDataType;
	typedef struct Stack
	{
		STDataType* a;
		int top;
		int capacity;
	}ST;
	void STInit(ST* ps, int n);
	void STDestroy(ST* ps);
	void STPush(ST* ps, STDataType x);
	void STPop(ST* ps);
	STDataType STTop(ST* ps);
	int STSize(ST* ps);
	bool STEmpty(ST* ps);
}
// Stack.cpp
#include"Stack.h"
namespace bit
{
	void STInit(ST* ps, int n)
	{
		assert(ps);
		ps->a = (STDataType*)malloc(n * sizeof(STDataType));
		ps->top = 0;
		ps->capacity = n;
	}
	// 栈顶
	void STPush(ST* ps, STDataType x)
	{
		assert(ps);
		// 满了, 扩容
		if (ps->top == ps->capacity)
		{
			printf("扩容\n");
			int newcapacity = ps->capacity == 0 ? 4 : ps->capacity
				* 2;
			STDataType* tmp = (STDataType*)realloc(ps->a,
				newcapacity * sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			}
			ps->a = tmp;
			ps->capacity = newcapacity;
		}
		ps->a[ps->top] = x;
		ps->top++;
	}
	//...
}
// Queue.h
#pragma once
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
namespace bit
{
	typedef int QDataType;
	typedef struct QueueNode
	{
		int val;
		struct QueueNode* next;
	}QNode;
	typedef struct Queue
	{
		QNode* phead;
		QNode* ptail;
		int size;
	}Queue;
	void QueueInit(Queue* pq);
	void QueueDestroy(Queue* pq);
	// ⼊队列
	void QueuePush(Queue* pq, QDataType x);
	// 出队列
	void QueuePop(Queue* pq);
	QDataType QueueFront(Queue* pq);
	QDataType QueueBack(Queue* pq);
	bool QueueEmpty(Queue* pq);
	int QueueSize(Queue* pq);
}
// Queue.cpp
#include"Queue.h"
namespace bit
{
	void QueueInit(Queue* pq)
	{
		assert(pq);
		pq->phead = NULL;
		pq->ptail = NULL;
		pq->size = 0;
	}
	// ...
}
// test.cpp
#include"Queue.h"
#include"Stack.h"
// 全局定义了⼀份单独的Stack
typedef struct Stack
{
	int a[10];
	int top;
}ST;
void STInit(ST* ps) {}
void STPush(ST* ps, int x) {}
int main()
{
	// 调⽤全局的
	ST st1;
	STInit(&st1);
	STPush(&st1, 1);
	STPush(&st1, 2);
	printf("%d\n", sizeof(st1));
	// 调⽤bit namespace的
	bit::ST st2;
	printf("%d\n", sizeof(st2));
	bit::STInit(&st2);
	bit::STPush(&st2, 1);
	bit::STPush(&st2, 2);
	return 0;
}
  • C++标准库都放在⼀个叫std(standard)的命名空间中。

命名空间的使用

编译查找⼀个变量的声明/定义时,默认只会在局部或者全局查找,不会到命名空间⾥⾯去查找。所以下⾯程序会编译报错。

cpp 复制代码
#include<stdio.h>
namespace zzy
{
int a = 0;
int b = 1;
}
int main()
{
// 编译报错:error C2065: "a": 未声明的标识符
printf("%d\n", a);
return 0;
}

所以我们要使⽤命名空间中定义的变量/函数,有三种⽅式:

  • 指定命名空间访问,项⽬中推荐这种⽅式。
cpp 复制代码
#include<stdio.h>
namespace zzy
{
	int a = 0;
	int b = 1;
}
// 指定命名空间访问
int main()
{
	printf("%d\n", zzy::a);
	return 0;
}
  • using将命名空间中某个成员展开,项⽬中经常访问的不存在冲突的成员推荐这种⽅式。
cpp 复制代码
#include<stdio.h>
namespace zzy
{
	int a = 0;
	int b = 1;
}
// using将命名空间中某个成员展开
using zzy::b;
int main()
{
	printf("%d\n", zzy::a);
	printf("%d\n", b);
	return 0;
}
  • 展开命名空间中全部成员,项⽬不推荐,冲突⻛险很⼤,⽇常⼩练习程序为了⽅便推荐使⽤。
cpp 复制代码
#include<stdio.h>
namespace zzy
{
	int a = 0;
	int b = 1;
}

// 展开命名空间中全部成员
using namespce zzy;
int main()
{
	printf("%d\n", a);
	printf("%d\n", b);
	return 0;
}

C++输⼊&输出

  • <iostream>是 Input Output Stream 的缩写,是标准的输⼊、输出流库,定义了标准的输⼊、输 出对象。
cpp 复制代码
#include<iostream>
  • std::cin 是 istream 类的对象,它主要⾯向窄字符(narrow characters (of type char))的标准输⼊流。
  • std::cout 是 ostream 类的对象,它主要⾯向窄字符的标准输出流。
  • <<是流插入运算符,>>是流提取运算符。(C语⾔还⽤这两个运算符做位运算左移/右移)
  • 使⽤C++输⼊输出更⽅便,不需要像printf/scanf输⼊输出时那样,需要⼿动指定格式,C++的输⼊ 输出可以⾃动识别变量类型(本质是通过函数重载实现的 ),其实最重要的是 C++的流能更好的⽀持⾃定义类型对象的输⼊输出。
  • cout/cin/endl等都属于C++标准库,C++标准库都放在⼀个叫std(standard)的命名空间中,所以要通过命名空间的使⽤⽅式去⽤他们。
  • ⼀般⽇常练习中我们可以using namespace std,实际项⽬开发中不建议using namespace std。
  • 没有包含<stdio.h>,也可以使⽤printf和scanf,在<iostream>间接包含了。vs系列 编译器是这样的,其他编译器可能会报错
cpp 复制代码
#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;
}

首先,和 C 语言不同的是,C++ 在进行输入输出时,并不需要像 C 语言那样在前面去定义诸如 "% s""% d" 这类格式化的标识。

接着说一下换行的操作方式。在 C++ 里实现换行有两种常见方法 :一种是在输出内容的后面紧接着输出换行符 "\n"(单引号或者双引号包裹均可);另一种则是在后面输出 "std::endl"。这里的 "endl" 其实是 "end line" 的缩写,意思就是结束一行。并且需要注意的是,"std::endl" 实际上是一个函数,它相较于单纯的 "\n" 来说,兼容性更强一些,因为它能够根据不同的平台自动进行相应的处理。

然后关于输入输出的函数使用方面。我们在 C++ 中既可以使用 C 语言的 "scanf" 和 "printf" 函数来进行输入输出操作,不过使用这两个函数时需要指定数据类型。而 C++ 自身的输入输出操作就不用特意去指定类型了,使用起来更加便捷。另外,C 和 C++ 的输入输出函数是可以混合在一起使用的,并不会产生什么问题。但如果想要精准控制打印出来的小数点的精度,这里建议使用 "printf" 函数,因为在 C++ 中要控制这个精度的话,操作起来会相对麻烦一些。

提高效率

在io需求⽐较⾼的地⽅,如部分⼤量输⼊的竞赛题中,加上以下3⾏代码可以提⾼C++IO效率

cpp 复制代码
#include<iostream>
using namespace std;
int main()
{
// 在io需求⽐较⾼的地⽅,如部分⼤量输⼊的竞赛题中,加上以下3⾏代码
// 可以提⾼C++IO效率
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
return 0;
}

C++缺省参数

  • 缺省参数是声明或定义函数时为函数的参数指定⼀个缺省值。在调⽤该函数时,如果没有指定实参 则采⽤该形参的缺省值,否则使⽤指定的实参,缺省参数分为全缺省和半缺省参数。(有些地⽅把 缺省参数也叫默认参数)
cpp 复制代码
#include <iostream>
#include <assert.h>
using namespace std;
void Func(int a = 0)
{
cout << a << endl;
}
int main()
{
Func(); // 没有传参时,使⽤参数的默认值
Func(10); // 传参时,使⽤指定的实参
return 0;
}
  • 全缺省就是全部形参给缺省值,半缺省就是部分形参给缺省值。
cpp 复制代码
#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;
}
  • C++规定半缺省参数必须从右往左依次连续缺省,不能间隔跳跃给缺省值。
cpp 复制代码
#include <iostream>
using namespace std;
// 半缺省
void Func2(int a = 10, int b, int c = 20)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl << endl;
}
int main()
{
Func2(100);
Func2(100, 200);
Func2(100, 200, 300);
return 0;
}
  • 带缺省参数的函数调⽤,C++规定必须从左到右依次给实参,不能跳跃给实参。
cpp 复制代码
#include <iostream>
using namespace std;
// 半缺省
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()
{
	Func2(100);
	Func2(, 200);
	Func2(, 200, );
	return 0;
}
  • 函数声明和定义分离时,缺省参数不能在函数声明和定义同时出现规定必须函数声明给缺省值。
cpp 复制代码
// Stack.h
#include <iostream>
#include <assert.h>
using namespace std;
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST* ps, int n = 4);
// Stack.cpp
#include"Stack.h"
// 缺省参数不能声明和定义同时给
void STInit(ST* ps, int n)
{
assert(ps && n > 0);
ps->a = (STDataType*)malloc(n * sizeof(STDataType));
ps->top = 0;
ps->capacity = n;
}
// test.cpp
#include"Stack.h"
int main()
{
ST s1;
STInit(&s1);
// 确定知道要插⼊1000个数据,初始化时⼀把开好,避免扩容
ST s2;
STInit(&s2, 1000);
return 0;
}

函数重载

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

参数类型不同

cpp 复制代码
#include<iostream>
using namespace std;

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;
}


int main()
{
	Add(10, 20);
	Add(10.1, 20.2);

	return 0;
}

参数类型不同

cpp 复制代码
#include<iostream>
using namespace std;

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


int main()
{

	f();
	f(10, 20);

	return 0;
}

参数顺序不同(本质还是类型不同)

cpp 复制代码
#include<iostream>
using namespace std;

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;
}

int main()
{

	f(10, 'a');
	f('a', 10);

	return 0;
}

常见错误

返回值不同

cpp 复制代码
#include<iostream>
using namespace std;
// 返回值不同不能作为重载条件,因为调⽤时也⽆法区分

void fxx()
 {}
 
 int fxx()
 {
         
	return 0;
 }

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

存在歧义

cpp 复制代码
#include<iostream>
using namespace std;

// 下面两个函数构成重载
// f()但是调用时,会报错,存在歧义,编译器不知道调用谁
void f1()
{
	cout << "f()" << endl;
}

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

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

引⽤

引⽤的概念和定义

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

类型& 引⽤别名=引⽤对象;

C++中为了避免引⼊太多的运算符,会复⽤C语⾔的⼀些符号,⽐如前⾯的>,这⾥引⽤也和取地址使⽤了同⼀个符号&,⼤家注意使⽤⽅法⻆度区分就可以。

a,b,c,d几个别名,都可访问这个地址

cpp 复制代码
#include<iostream>
using namespace std;

int main()
{
	int a = 0;
	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;
}

引⽤的特性

  • 引⽤在定义时必须初始化

错误写法:

cpp 复制代码
int& ra;

编译报错:"ra" :必须初始化引⽤

  • ⼀个变量可以有多个引⽤
cpp 复制代码
    int a = 0;
	int& b = a;
	int& c = a;
  • 引⽤⼀旦引⽤⼀个实体,再不能引⽤其他实体
cpp 复制代码
#include <iostream>

int main() {
    int num1 = 10;
    int& ref = num1;  // ref引用了num1这个实体

    int num2 = 20;

    // 以下这种直接改变引用关系的操作在C++中是不被允许的,实际编译会报错
    ref = &num2;  // 错误的尝试,想要让ref直接引用num2,但这种语法是错误的

    std::cout << "ref的值(引用num1时): " << ref << std::endl;

    return 0;
}

引⽤的使⽤

引⽤在实践中主要是于引⽤传参 和引⽤做返回值中减少拷⻉提⾼效率和改变引⽤对象时同时改变被引⽤对象。

cpp 复制代码
#include<iostream>
using namespace std;
struct A
{
    int arr[1000];
};

void func(A& aa)
{

}

int main()
{
    A aa1;
    func(aa1);//传指针的话我们就直接传过去指针的4个字节
    //如果将这个结构体传过去的话,那么就是4000个字节,我们需要额外进行拷贝的操作
    //我们在这里使用引用的话,更方便些,同样和指针一样不需要进行额外的拷贝的操作
    return 0;
}

取别名没有额外的开辟空间,通过应用的方式减少额外的拷贝:

cpp 复制代码
#include<iostream>
using namespace std;

void Swap(int& rx, int& ry)
{
	int tmp = rx;
	rx = ry;
	ry = tmp;
}
int main()
{
	int x = 0, y = 1;
	cout << x << " " << y << endl;
	Swap(x, y);
	cout << x << " " << y << endl;
	return 0;
}

形参 rx 和 ry 分别是实参 x 和 y 的别名,那么对 rx 和 ry 进行交换操作,实际上就等同于对 x 和 y 进行交换操作。而且在这种情况下,当改变作为别名的引用对象时,与之对应的被引用对象也会随之改变。需要注意的是,这里通过指针其实也能够实现类似的功能,只是相比较而言,使用指针来达成此目的会更加麻烦一些。

引⽤传参跟指针传参功能是类似的,引⽤传参相对更⽅便⼀些。

以栈的尾插操作举例:

  • 指针传参
cpp 复制代码
#include <iostream>
using namespace std;

typedef struct ListNode {
    int val;
    struct ListNode* next;
} LTNode;

// 在链表末尾添加节点的函数
void ListPushBack(LTNode** phead, int x) {
    LTNode* newNode = new LTNode;
    newNode->val = x;
    newNode->next = NULL;
    if (*phead == NULL) {
        // 如果链表为空,新节点就是头节点
        *phead = newNode;
    }
    else {
        LTNode* cur = *phead;
        while (cur->next != NULL) {
            cur = cur->next;
        }
        // 将新节点添加到链表末尾
        cur->next = newNode;
    }
}
int main() {
    LTNode* plist = NULL;
    ListPushBack(&plist, 1);

    return 0;
}
  • 引用传参
cpp 复制代码
#include <iostream>
using namespace std;

typedef struct ListNode {
    int val;
    struct ListNode* next;
} LTNode, * PNode;

// 在链表末尾添加节点的函数
void ListPushBack(PNode& phead, int x) {
    PNode newnode = (PNode)malloc(sizeof(LTNode));
    newnode->val = x;
    newnode->next = NULL;

    if (phead == NULL) {
        phead = newnode;
    }
    else {
        PNode cur = phead;
        while (cur->next != NULL) {
            cur = cur->next;
        }
        cur->next = newnode;
    }
}

int main() {
    PNode plist = NULL;
    ListPushBack(plist, 1);

    return 0;
}

引⽤返回值的场景相对⽐较复杂

这⾥简单讲了⼀下场景,还有⼀些内容后续类和对象章节中会继续深⼊讲解。

cpp 复制代码
int* STTop(ST& rs)
{
	assert(rs.top > 0);

	//return &(rs.a[rs.top - 1]);
	return rs.a + (rs.top - 1);
}

STTop(st1)++;

要注意的是,"STTop (st1)++" 的操作是会报错的。这是因为在传值返回的机制下,函数返回值时会临时开辟出一块空间(tmp),用于存放要传递的值,然后将这个值传过去。而一旦接收方完成了对这个值的接收,这块临时开辟的空间(tmp)就会随即被销毁。如此一来,当我们尝试对函数的返回值进行像 "++" 这样的自增操作时,实际上是无法按照我们预期的那样对原本相关的数据进行操作的,因为此时这个自增操作是作用在了已经被销毁的那块临时空间所对应的临时变量上面,而不是我们原本期望的目标数据上

cpp 复制代码
int& STTop(ST& rs)
{
	assert(rs.top > 0);

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

STTop(st1)++;

函数 STTop 返回的是栈顶元素值的别名,即通过返回引用的方式给它创建了可直接操作的 "替身"。所以执行 STTop (st1)++ 时,自增操作能准确作用在栈顶元素值上。而且返回引用跨过了传值返回需创建临时变量(tmp)那步,传值返回时临时空间存放值后会销毁,无法进行类似 ++ 操作,而返回引用就可方便地对返回的引用进行操作啦。

注意:不是所有的返回都能用引用返回的

cpp 复制代码
int& func1()
{
    int ret = 10;
    ret++;
 
    //......
    return ret;
}

当函数执行完毕后,局部变量 ret 的生命周期就结束了,其所占用的栈内存空间会被释放掉。此时返回的引用就变成了一个指向已释放内存的 "悬空引用"。

那为什么之前写的栈相关代码能够进行返回操作呢?这是因为在那部分代码里,我们的数据是在堆上动态开辟的呀。当进行返回操作时,这些在堆上开辟的数据会一直存在着呢,除非我们主动去执行 free 操作来释放它,不然它就会一直保留在内存当中。所以呢,基于这种情况,我们就能够顺利地进行返回操作啦。

然而在当前所讨论的这段代码中,情况可就不一样啦。在这里,相关的数据一旦函数执行结束就会被销毁掉哦,那么引用就会变成**"悬空引用"**。所以呀,当我们使用引用返回的时候,一定要先判断一下这块空间在函数执行完毕后是否依然存在哦,只有这样才能避免出现问题呢。

引⽤和指针在实践中相辅相成,功能有重叠性,但是各有特点,互相不可替代。

C++的引⽤跟其他 语⾔的引⽤(如Java)是有很⼤的区别的,除了⽤法,最⼤的点,C++引⽤定义后不能改变指向, Java的引⽤可以改变指向。

C++的引用为什么不能替代指针

在 C++ 中,引用是无法替代指针的。咱们以链表为例来说明一下吧。要知道,链表的节点在物理存储上并非始终是连续排列的哦,而是每个节点都存储着指向下一个节点的指针。

假设现在要将链表中的节点 2 删除,那我们就需要把节点 1 的 next 指针的指向改变,让它直接指向节点 3,通过这样对指针指向的改变操作,就能顺利实现删除节点 2 的目的啦。

然而,引用可就不一样啦,引用一旦确定了所引用的对象,它是不能改变指向的哦。这也就是为什么在 C++ 里,引用不能去替代指针的原因所在啦。

⼀些主要⽤C代码实现版本数据结构教材中,使⽤C++引⽤替代指针传参,⽬的是简化程序,避开 复杂的指针,但是很多同学没学过引⽤,导致⼀头雾⽔。

cpp 复制代码
#include<iostream>
using namespace std;
typedef struct SeqList
{
	int a[10];
	int size;
}SLT;
// ⼀些主要⽤C代码实现版本数据结构教材中,使⽤C++引⽤替代指针传参,
// ⽬的是简化程序,避开复杂的指针,但是很多同学没学过引⽤,导致⼀头雾⽔。

void SeqPushBack(SLT & sl, int x)
{}
typedef struct ListNode
{
	int val;
	struct ListNode* next;
}LTNode, * PNode;
// 指针变量也可以取别名,这⾥LTNode*& phead就是给指针变量取别名
// 这样就不需要⽤⼆级指针了,相对⽽⾔简化了程序

//void ListPushBack(LTNode** phead, int x)
 //void ListPushBack(LTNode*& phead, int x)
	void ListPushBack(PNode& phead, int x)
{
	PNode newnode = (PNode)malloc(sizeof(LTNode));
	newnode->val = x;
	newnode->next = NULL;
	if (phead == NULL)
	{
		phead = newnode;
	}
	else
	{
	}
}

int main()
{
	PNode plist = NULL;
	ListPushBack(plist, 1);
	return 0;
}

拓展:越界是不一定报错的

cpp 复制代码
int main()
{
 
 
    int a[10] = {0};
    //越界是不一定报错的
    //越界读的话不报错   读就是打印里面的值
    cout << a[10] << endl;
    cout << a[11] << endl;
    cout << a[12] << endl;
 
    //越界写不一定报错    写就是进行赋值
    a[10] = 1;
    a[12] = 1;
    return 0;
}
//编译器系统对越界行为是一种抽查行为
//不一定会报错的

const引⽤

  • 可以引⽤⼀个const对象,但是必须⽤const引⽤。const引⽤也可以引⽤普通对象,因为对象的访问权限在引⽤过程中可以缩⼩,但是不能放⼤

权限的放大和缩小:

cpp 复制代码
#include<iostream>
using namespace std;
int main()
{
	// 权限不能放大
   const int a = 10;
   //在引用的过程中权限不能放大
    //int& ra = a;
	return 0;
}

我们先定义了一个常量 a,其声明方式为 const int a = 10;。由于 a 被定义成了常量,它是不能被修改的,所以也就不应该为它取别名。因为一旦为 a 取了别名,就存在对 a 进行修改的风险,这是不符合常量的不可修改特性的。

另外,在引用的操作过程中,有一个重要原则就是权限不能被放大 。就像这里如果写成 int& ra = a; 这种试图为常量 a 取引用别名的做法,实际上就是违背了权限不能放大的原则,因为这可能会导致意外修改常量 a 的值。

cpp 复制代码
#include<iostream>
using namespace std;
int main()
{
	
	// //权限可以缩小
	int b = 1;
	const int& rb = b;
    //rb不能进行修改,b能被修改
    //rb++;
    b++;

	return 0;
}

我们来看这样一种情况,即通过 const 来引用一个普通的对象。在这种情形下,是允许权限进行缩小操作的。比如,我们先定义了一个普通的整型变量 b,并将其初始化为 1,即 int b = 1;。然后,我们创建了一个常量引用 rb 来指向 b,声明语句为 const int& rb = b;这里要注意的是,通过 const 修饰的引用 rb 是不能进行修改操作的,而它所引用的原始对象 b 却依然可以被修改。例如,如果我们尝试对 rb 进行自增操作,像 rb++; 这样的语句是不被允许的,会导致编译错误。但对于原始变量 b,我们可以对其进行自增操作,如 b++; 是完全合法的。

cpp 复制代码
#include<iostream>
using namespace std;
int main()
{

	//不是权限的放大
    //x不能修改,但是我们在下面将x拷贝成y
    //我们修改y的话是对x没有影响的
    const int x = 0;
    int y = x;//这里是拷贝赋值

	return 0;
}

这里要明确一点,并不是在进行权限的放大操作哦。我们先定义了一个常量 x,通过 const int x = 0; 这样的语句将其设定为不可修改的值,也就是 x 是不能被更改的。但是呢,接下来我们做了一个操作,就是把 x 拷贝给了 y,具体的语句是 int y = x;,这里其实就是在进行拷贝赋值啦。然后要知道的是,即便我们之后对 y 进行修改,那对于 x 来说也是没有任何影响的哦,因为它们之间只是进行了一次拷贝赋值的操作呀。

  • 不需要注意的是类似 int& rb = a*3; double d = 12.34; int& rd = d; 这样⼀些场景下a*3的和结果保存在⼀个临时对象中, int& rd = d 也是类似,在类型转换中会产⽣临时对象存储中间值,也就是时,rb和rd引⽤的都是临时对象,⽽C++规定临时对象具有常性,所以这⾥就触发了权限放⼤,必须要⽤常引⽤才可以。
cpp 复制代码
#include<iostream>
using namespace std;
int main()
{
	int a = 10;
	const int& ra = 30;
	// 编译报错: "初始化" :⽆法从"int"转换为"int& "
		// int& rb = a * 3;
		const int& rb = a * 3;
	double d = 12.34;
	// 编译报错:"初始化" :⽆法"double"转换为"int& "
		// int& rd = d;
		const int& rd = d;
	return 0;
}
  • 所谓临时对象就是编译器需要⼀个空间暂存表达式的求值结果时临时创建的⼀个未命名的对象, C++中把这个未命名对象叫做临时对象。
cpp 复制代码
#include<iostream>
using namespace std;
void f1(const int& rx)
{
    // 在这里,我们在函数接收引用类型的形参时,为了确保不会通过这个形参去改变传入的实参,
    // 所以在形参前面加上了 `const` 进行修饰。
}

int main()
{
    // 定义一个常量 `xx`,其值为 `20`
    const int xx = 20;

    // 定义一个普通整型变量 `a`,初始值为 `10`
    int a = 10;

    // 下面这行代码如果写成这样:`int& rb = a * 3;` 是存在问题的。
    // 因为这里引用的是 `a * 3` 这个表达式的整体,而表达式的结果会用一个临时对象进行存储。
    // 按照C++的规定,临时对象具有常性,就好像已经被 `const` 修饰过一样。
    // 所以如果直接用普通引用去引用这个临时对象,就相当于触发了权限放大的情况,这是不允许的。
    // 正确的做法是像下面这样,在引用前面加上 `const`,使其成为常引用。
    const int& rb = a * 3;

    // 这里的 `rb` 引用的就是那个临时对象,也就是 `a * 3` 的结果存储在了这个临时变量里面。

    // 再看下面的情况,定义一个双精度浮点数 `d`,值为 `12.34`
    double d = 12.34;

    // 如果直接写成 `int &rd = d;` 是不行的。
    // 因为我们不能用 `int` 类型的数据去引用其他类型的数据。
    // 当进行这种不同类型之间的转换时,中间会生成临时变量,
    // 并且这个临时对象同样具有常性,就如同已经被 `const` 修饰了一样。
    // 所以这里也触发了权限放大的情况,那么左边同样也要进行 `const` 修饰的操作,
    // 正确的写法如下:
    const int& rd = d;

    // 接下来进行函数调用
    f1(xx);  // 可以将常量对象 `xx` 传递给函数 `f1`
    f1(a);   // 也可以将普通值 `a` 传递给函数 `f1`
    f1(a * 3);  // 还可以将表达式 `a * 3` 的结果(也就是对应的临时对象)传递给函数 `f1`
    f1(d);   // 同样可以将带有类型转换的 `d`(实际传递的是转换过程中生成的临时对象)传递给函数 `f1`

    // 如果采用 `const` 引用进行传参的话,其适用范围是比较宽泛的,可以接收多种类型的对象,
    // 包括普通对象、临时对象以及 `const` 对象等。

    return 0;
}

并且要记住哦,临时对象是具有常量性的值的,就相当于已经被 const 修饰了,是不能被修改的。

同时,const 引用具有比较广泛的引用能力,它能引用普通对象引用临时对象 ,也能引用 const 对象

拓展:指针上的权限问题

cpp 复制代码
#include<iostream>
using namespace std;
int main()
{
    // 首先定义一个常量a,并初始化为10,由于它被声明为const,所以其值是不能被修改的。
    const int a = 10;

    // 接着定义一个指针p1,它指向常量a。这里的指针类型是const int*,意味着通过这个指针不能修改它所指向的数据。
    const int* p1 = &a;

    // 如果像下面这样写是错误的,因为这属于权限的放大。这里试图将指向常量的指针p1赋值给一个普通指针p2,
    // 普通指针p2是可以用来修改它所指向的数据的,而p1指向的是常量a,不应该通过这种赋值来获得可修改的权限。
    // int* p2 = p1;

    /*
    当我们像上面那样定义了指针p1指向常量a后,就意味着p1已经被固定指向a了,
    我们就不能再去修改它的指向了。这是因为我们在定义p1时,在指针类型中通过const进行了限制,
    具体来说,当const在*右边时,就表明这个指针所指向的变量已经被限制,不能随意改变其指向了。

    而当const在*左边时,比如const int*这种形式,就意味着我们不能通过这个指针对它所指向的数据进行修改操作。
    */

    // 再定义一个普通整型变量b,并初始化为20。
    int b = 20;

    // 然后定义一个指针p3,它指向变量b,也就是说p3存储的是b的地址,通过这个指针p3是可以对b进行修改操作的。
    int* p3 = &b;

    // 接着定义一个指针p4,它同样指向变量b,不过这里的指针类型是const int*,这就使得我们通过p4只能对b进行读取操作,而不能进行修改操作。
    const int* p4 = p3;


    // 下面这种情况,定义了一个指针p4,这里的const在*右边,它的完整声明是int* const p4 = &b;,
    // 这表示一旦p4被初始化指向b后,就不能再修改它的指向了,它会一直指向这个初始设定的数。
    int* const p4 = &b;

    // 然后将p4赋值给p5是可以的,因为p5是一个普通指针,它可以接收p4的值(也就是b的地址),并且可以通过它来对b进行操作(前提是符合相应的权限规则)。
    int* p5 = p4;


    return 0;
}

在指针与const的应用中有如下规律:

要是不想让指针本身的指向被修改,那就把指针放在*右边,写成***const p** 的形式,这样指针就会一直指向一个数,不能改变其指向。

要是不想让通过指针去修改它所指向的内容,那就把const放在*左边,写成const *p 的形式,这样就将*p所指向的内容固定住,不能通过这个指针去修改所指向内容的大小等特性。

需要注意的是,在类似 int& rb = a * 3; double d = 12.34; int& rd = d; 这样的场景下,a * 3的结果会保存在一个临时对象中,int& rd = d的情况也是类似,在类型转换过程中会产生临时对象来存储中间值。也就是说,rb和rd引用的都是临时对象,而C++规定临时对象具有常性,所以在这些情况下会触发权限放大,必须要用常引用(如const int&等形式)才能正确处理,以避免权限相关的错误。

指针和引用的关系

在C++中,引用(reference)和指针(pointer)都是用于间接访问另一个对象的方式,但它们之间存在一些重要的区别。以下是引用和指针的一些关键差异点:

  • 内存分配
  1. 引用并不占用额外的内存空间,它只是给已存在的变量一个别名。
  2. 指针是一个变量,它存储的是另一个变量的地址,因此它会占用内存空间。
  • 初始化要求
  1. 引用在创建时必须初始化,并且一旦初始化,就不能改变它引用的对象。
  2. 指针可以延迟初始化,也可以重新指向不同的对象。
  • 解引用操作
  1. 引用不需要显式地进行解引用操作就可以访问其绑定的对象。
  2. 使用指针访问对象时,通常需要使用解引用运算符(*)来获取指向的数据。
  • sizeof操作
  1. sizeof作用于引用时,返回的是引用所引用对象的类型大小。
  2. sizeof作用于指针时,返回的是指针本身的大小,这通常是平台相关的(如32位系统上是4字节,64位系统上是8字节)。
  • 安全性
  1. 引用提供了一种更安全的方式来处理数据,因为它们不能被设置为NULL,也不能重新指向另一个对象。
  2. 指针可以被设置为NULL,可以重新指向,这也增加了出错的可能性,比如空指针解引用或野指针问题。

inline

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

C语言中宏定义的缺点:

cpp 复制代码
#include <iostream>

// 实现⼀个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() {
    // 使用ADD宏计算1和2的和,并将结果存储在ret中
    int ret = ADD(1, 2); // 宏是替换机制的,这里会将ADD(1, 2)替换为((1) + (2))
    std::cout << ret << std::endl;

    // 第一个问题相关的调用,单纯输出1和2通过宏计算后的结果
    std::cout << ADD(1, 2) << std::endl;

    // 第二个问题相关的调用,先通过宏计算1和2的和,然后再乘以5
    // 这里能体现出括号对于保证优先级的重要性,如果宏定义的括号不正确,可能会导致计算顺序错误
    std::cout << ADD(1, 2) * 5 << std::endl;

    int x = 1, y = 2;
    // 第三个问题相关的调用,将x和y进行按位与、按位或运算后再通过宏进行加法运算
    // 实际替换后就变成了 (x&y + x|y),这里也能看出宏定义中括号的作用,确保运算顺序正确
    ADD(x & y, x | y); 

    return 0;
}

/*
宏函数缺点很多,比如可读性差、调试困难等,但是它基于替换机制,调用时不用建立栈帧,能提高效率,开销小。

大概就是因为宏函数存在这些优缺点吧,我们的祖师爷觉得不太能忍受它的一些不足,于是就创建了这个inline关键字来解决一些相关的问题啦。
*/
  • inline对于编译器⽽⾔只是⼀个建议 ,也就是说,你加了inline编译器也可以选择在调⽤的地⽅不展开,不同编译器关于inline什么情况展开各不相同,因为C++标准没有规定这个。inline适⽤于频繁 调⽤的短⼩函数,对于递归函数,代码相对多⼀些的函数,加上inline也会被编译器忽略。
  • C语⾔实现宏函数也会在预处理时替换展开,但是宏函数实现很复杂很容易出错的,且不⽅便调 试,C++设计了inline⽬的就是替代C的宏函数
  • vs编译器debug版本下⾯默认是不展开inline的,这样⽅便调试,debug版本想展开需要设置⼀下 以下两个地⽅。
  • inline不建议声明和定义分离到两个⽂件,分离会导致链接错误。因为inline被展开,就没有函数地址,链接时会出现报错。
cpp 复制代码
#include <iostream>

// 定义一个内联函数,用于计算两个整数的和
inline int add(int a, int b) {
    return a + b;
}

int main() {
    int num1 = 5;
    int num2 = 3;

    // 调用内联函数add
    int result = add(num1, num2);

    std::cout << "两数之和为: " << result << std::endl;

    return 0;
}

nullptr

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

cpp 复制代码
#ifndef NULL
    #ifdef __cplusplus
        #define NULL 0
    #else
        #define NULL ((void *)0)
    #endif
#endif
  • C++中NULL可能被定义为字⾯常量0,或者C中被定义为⽆类型指针(void*)的常量。不论采取何种定义,在使⽤空值的指针时,都不可避免的会遇到⼀些⿇烦,本想通过f(NULL)调⽤指针版本的 f(int*)函数,但是由于NULL被定义成0,调⽤了f(intx),因此与程序的初衷相悖f((void*)NULL); 调⽤会报错。
  • C++11中引⼊nullptr,nullptr是⼀个特殊的关键字,nullptr是⼀种特殊类型的字⾯量,它可以转换成任意其他类型的指针类型。使⽤nullptr定义空指针可以避免类型转换的问题,因为nullptr只能被隐式地转换为指针类型,⽽不能被转换为整数类型。
cpp 复制代码
#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(int x)`,因为这里传入的是一个整型常量 `0`,它与第一个函数的整型参数类型匹配。

    // 要是写成 `f(NULL);`,在C语言风格下,这可能会调用第二个函数 `f(int* ptr)`,但在C++中,这样使用 `NULL` 来表示空指针存在类型转换的问题哦。

    // 而在C++中,我们更推荐使用 `nullptr` 来定义空指针,就像下面这样写 `f(nullptr);`,这样可以有效避免类型转换的问题,并且它会准确地调用第二个函数 `f(int* ptr)`,因为 `nullptr` 可以明确地表示一个空指针类型,与第二个函数接受的整型指针参数类型相匹配。

    f(nullptr);

    return 0;
}
相关推荐
2401_857439691 小时前
SSM 架构下 Vue 电脑测评系统:为电脑性能评估赋能
开发语言·php
SoraLuna2 小时前
「Mac畅玩鸿蒙与硬件47」UI互动应用篇24 - 虚拟音乐控制台
开发语言·macos·ui·华为·harmonyos
xlsw_2 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
Dream_Snowar3 小时前
速通Python 第三节
开发语言·python
唐诺3 小时前
几种广泛使用的 C++ 编译器
c++·编译器
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
冷眼看人间恩怨4 小时前
【Qt笔记】QDockWidget控件详解
c++·笔记·qt·qdockwidget
信号处理学渣4 小时前
matlab画图,选择性显示legend标签
开发语言·matlab
红龙创客4 小时前
某狐畅游24校招-C++开发岗笔试(单选题)
开发语言·c++
Lenyiin4 小时前
第146场双周赛:统计符合条件长度为3的子数组数目、统计异或值为给定值的路径数目、判断网格图能否被切割成块、唯一中间众数子序列 Ⅰ
c++·算法·leetcode·周赛·lenyiin