恋恋风辰的个人博客


  • Home

  • Archives

  • Categories

  • Tags

  • Search

零基础C++(7) 引用类型

Posted on 2024-09-21 | In 零基础C++

const限定符

1 const 的定义与作用

const 是 C++ 关键字,用于指示变量的值不可修改。通过使用 const,可以提高代码的安全性与可读性,防止无意中修改变量的值。

2 const 在变量声明中的位置

const 关键字通常放在变量类型之前,例如:

1
const int a = 10;

也可以放在类型之后,但这种用法较少见:

1
int const a = 10;

可以用一个变量初始化常量, 也可以将一个常量赋值给一个变量

1
2
3
4
5
//可以用一个变量初始化常量
int i1 = 10;
const int i2 = i1;
//也可以将一个常量赋值给一个变量
int i3 = i2;

const变量必须初始化

1
2
//错误用法,const变量必须初始化
//const int i4;

3 编译器如何处理 const 修饰的变量

const 修饰的变量在编译时会被视为只读,尝试修改其值会导致编译错误。此外,编译器可能会对 const 变量进行优化,如将其存储在只读内存区域。

注意

默认状态下,const对象仅在文件内有效

当以编译时初始化的方式定义一个const对象时,就如对bufSize的定义一样:

1
const int bufSize = 512;

编译器将在编译过程中把用到该变量的地方都替换成对应的值。也就是说,编译器会找到代码中所有用到bufSize的地方,然后用512替换。

为了执行上述替换,编译器必须知道变量的初始值。

如果程序包含多个文件,则每个用了const对象的文件都必须得能访问到它的初始值才行。要做到这一点,就必须在每一个用到变量的文件中都有对它的定义.

为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量。

我们创建一个global.h文件和global.cpp文件, 我们知道头文件只做变量的声明,之前我们在头文件添加变量的定义会导致连接错误。

那如果我们添加const变量的定义

1
2
3
4
#ifndef DAY08_CONST_GLOBAL_H
#define DAY08_CONST_GLOBAL_H
const int bufSize = 100;
#endif //DAY08_CONST_GLOBAL_H

在main.cpp和global.cpp中包含global.h,发现可以编译通过,虽然main.cpp和global.cpp中包含了同名的bufSize,但却是不同的变量,运行程序可以编译通过。

有时候我们不想定义不同的const变量,可以在global.h中用extern声明bufSize

1
extern const int bufSize2;

在global.cpp中定义

1
const int bufSize2 = 10;

同样可以编译通过。

为了验证我们的说法,我们可以在global.h中声明一个函数,用来打印两个变量的地址

1
2
//打印bufSize地址和bufSize2地址
extern void PrintBufAddress();

在global.cpp中实现PrintBufAddress()

1
2
3
4
void PrintBufAddress(){
std::cout << "global.cpp buf address: " << &bufSize << std::endl;
std::cout << "global.cpp buf2 address: " << &bufSize2 << std::endl;
}

然后我们在main.cpp中调用PrintBufAddress()函数,并且在main.cpp中打印两个变量地址

1
2
3
4
5
PrintBufAddress();
//输出bufSize地址
std::cout << "main.cpp buf address is " << &bufSize << std::endl;
//输出bufSize2地址
std::cout << "main.cpp buf2 address is " << &bufSize2 << std::endl;

程序输出

1
2
3
4
global.cpp buf address: 0x7ff67a984040
global.cpp buf2 address: 0x7ff67a984044
main.cpp buf address is 0x7ff67a984000
main.cpp buf2 address is 0x7ff67a984044

可以看出global.cpp中的bufSize和main.cpp中的bufSize不是同一个变量

技巧

如果想在多个文件之间共享const对象,必须在变量的定义之前添加extern关键字。

const的引用

可以把引用绑定到const对象上,就像绑定到其他对象上一样,我们称之为对常量的引用(reference to const)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象:

1
2
3
4
//定义常量
const int ci = 1024;
//用常量引用绑定常量
const int &r1 = ci;

不能修改常量引用的值

1
2
//不能修改常量引用的值
//r1 = 2048;

也不能用非常量引用指向一个常量对象

1
2
//也不能用非常量引用指向一个常量对象
//int& r2 = ci;

术语

常量引用是对const的引用

企业中,C++程序员们经常把词组“对const的引用”简称为“常量引用

允许将const引用绑定一个非const变量

1
2
3
int i5 = 1024;
//允许将const int& 绑定到一个普通的int对象上
const int &r5 = i5;

常量引用绑定字面量

1
2
//常量引用绑定字面量
const int &r6 = 1024;

常量引用绑定表达式计算的值

1
2
3
//常量引用绑定表达式计算的值
const int &r7 = r6 * 2;
const int &r8 = i5 * 2 + 10;

思考1

下面的代码能编译通过吗?

1
2
double dval = 3.14;
int & rd = dval;

答案

1
2
3
//错误用法,类型不匹配
double dval = 3.14;
int & rd = dval;

思考2

下面的代码能编译通过吗?

1
2
double dval = 3.14;
const int & ri = dval;

答案

1
2
3
//编译通过
double dval = 3.14;
const int & ri = dval;

上面的代码相当于

1
2
3
//上面代码会做隐士转换,相当于下面代码
const int temp = dval;
const int &rt = temp;

在这种情况下,ri绑定了一个临时量(temporary)对象。

所谓临时量对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。

C++程序员们常常把临时量对象简称为临时量。

对const的引用可能引用一个并非const的对象必须认识到,常量引用仅对引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未作限定。因为对象也可能是个非常量,所以允许通过其他途径改变它的值:

1
2
3
4
5
6
7
int i9 = 1024;
//非常量引用绑定i9
int &r9 = i9;
//常量引用绑定一个变量
const int &r10 = i9;
//可以同过非常量引用修改i9的值
r9 = 2048;

指针和const

指向常量的指针(pointer to const)

可以令指针指向常量或非常量。类似于常量引用,指向常量的指针(pointer to const)不能用于改变其所指对象的值。

要想存放常量对象的地址,只能使用指向常量的指针:

1
2
3
4
5
6
7
8
//PI 是一个常量,它的值不能改变
const double PI = 3.14;
//错误,ptr是一个普通指针
//double * ptr = &PI;
//正确,cptr可以指向一个双精度常量
const double *cptr = &PI;
//错误,不能给*ptr赋值
//*cptr = 3.14;

指针的类型必须与其所指对象的类型一致,但是允许令一个指向常量的指针指向一个非常量对象

1
2
3
4
//可以用指向常量的指针指向一个非常量
int i10 = 2048;
//ptr指向i10
int *cptr2 = &i10;

const指针

指针是对象而引用不是,因此就像其他对象类型一样,允许把指针本身定为常量。

常量指针(const pointer)必须初始化,而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。

把*放在const关键字之前用以说明指针是一个常量,这样的书写形式隐含着一层意味,即不变的是指针本身的值而非指向的那个值:

1
2
3
4
5
6
int errNumb = 0;
//curErr是一个常量指针,指向errNumb
int * const curErr = &errNumb;
const double pi2 = 3.14;
//pip 是一个指向常量对象的常量指针
const double *const pip = &pi2;

指针本身是一个常量并不意味着不能通过指针修改其所指对象的值,能否这样做完全依赖于所指对象的类型

1
2
3
4
5
6
//错误,pip是一个指向常量的指针
//*pip = 2.72;
//可以修改常量指针指向的内容
*curErr = 1024;
//可以修改常量指针指向的地址
//curErr = &i10;

顶层const

指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题

用名词顶层const(top-level const)表示指针本身是个常量,而用名词底层const(low-level const)表示指针所指的对象是一个常量。

顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。

底层const则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层const也可以是底层const,这一点和其他类型相比区别明显:

1
2
3
4
5
6
7
8
9
10
11
int i = 0;
//不能改变p1的值,这是一个顶层const
int * const pi = &i;
//不能改变ci的值,这是一个顶层const
const int ci = 42;
//允许改变p2的值,这是一个底层const
const int * p2 = &ci;
//靠右边的const是顶层const,靠左边的const是底层const
const int * const p3 = p2;
//用于声明引用的const都是底层const
const int &r = ci;

底层const的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型必须能够转换

1
2
3
//指针赋值要注意关注底层const
//p2拥有底层const,p4无底层const,所以无法赋值
//int * p4 = p2;

constexpr和常量表达式

常量表达式(const expression)是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。后面将会提到,C++语言中有几种情况下是要用到常量表达式的。

我们先在global.h中声明一个全局函数返回固定大小

1
extern int GetSize();

在global.cpp中实现

1
2
3
int GetSize(){
return 20;
}

然后我们用const定义一些常量表达式

一个对象(或表达式)是不是常量表达式由它的数据类型和初始值共同决定,例如

1
2
3
4
5
6
7
8
9
10
{
//max_files是一个常量表达式
const int max_files = 20;
//limit是一个常量表达式
const int limit = max_files + 10;
//staff_size不是常量表达式,无const声明
int staff_size = 20;
//sz不是常量表达式,运行时计算才得知
const int sz = GetSize();
}

尽管staff_size的初始值是个字面值常量,但由于它的数据类型只是一个普通int而非const int,所以它不属于常量表达式。

另一方面,尽管sz本身是一个常量,但它的具体值直到运行时才能获取到,所以也不是常量表达式。

在一个复杂系统中,很难(几乎肯定不能)分辨一个初始值到底是不是常量表达式。

当然可以定义一个const变量并把它的初始值设为我们认为的某个常量表达式,但在实际使用时,尽管要求如此却常常发现初始值并非常量表达式的情况。

C++11新标准

C++11新标准规定,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化:

1
2
3
4
5
6
//20是一个常量表达式
constexpr int mf = 20;
//mf+1是一个常量表达式
constexpr int limit = mf + 10;
//错误,GetSize()不是一个常量表达式,需要运行才能返回
//constexpr int sz = GetSize();

尽管不能使用普通函数作为constexpr变量的初始值,新标准允许定义一种特殊的constexpr函数。

这种函数应该足够简单以使得编译时就可以计算其结果,这样就能用constexpr函数去初始化constexpr变量了。

我们在global.h中定义一个constexpr函数

1
2
3
inline constexpr int GetSizeConst() {
return 1;
}

为了避免在多个源文件中包含同一个头文件而导致的多重定义错误,可以将 constexpr 函数声明为 inline。

inline 关键字允许在多个翻译单元中定义同一个函数,而不会引起链接错误。

接下来在定义一个constexpr变量就行了

1
constexpr int sz = GetSizeConst();

指针和constexpr

必须明确一点,在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关:

1
2
3
4
//p是一个指向整形常量的指针
const int * p = nullptr;
//q是一个指向整数的常量指针
constexpr int *q = nullptr;

一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象。

函数体内定义的变量一般来说并非存放在固定地址中,因此constexpr指针不能指向这样的变量。

定义于所有函数体之外的对象其地址固定不变,能用来初始化constexpr指针

global_i是一个全局变量

1
2
3
4
5
//constexpr指针只能绑定固定地址
//constexpr int *p = &mvalue;
constexpr int *p = nullptr;
//可以绑定全局变量,全局变量地址固定
constexpr int *cp = &global_i;

可以修改constexpr指向的内容

1
2
3
constexpr int *p = &global_i;
//修改p指向的内容数据
*p = 1024;

问题

global_i是一个全局变量,下面这个指针是什么类型?能否修改cp指向的数据的内容(*cp = 200)?

1
constexpr const int * cp = &global_i;

零基础C++(6) 指针类型

Posted on 2024-09-17 | In 零基础C++

指针基础

在C++中,指针是一种特殊的变量,它存储的是另一个变量的内存地址,而不是数据本身。通过使用指针,我们可以直接访问和操作内存中的数据。指针也叫做地址。

和引用的区别

指针(pointer)是“指向(point to)”另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问。然而指针与引用相比又有很多不同点。

其一,指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。

其二,指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。

1
2
3
4
5
6
//声明一个指向整数的指针,可以不赋初值
int *ptr;
//ip1和ip2都是指向int类型对象的指针
int *ip1, *ip2;
//dp2是指向double类型对象的指针,dp是double型对象
double dp, *dp2;

这里,ptr 是一个指针变量,它可以存储一个整数的内存地址。

指针赋值

我们可以通过如下方式(&变量)获取一个变量的地址

1
变量地址 = &变量名

指针可以通过两种方式赋值:

  1. 直接赋值内存地址:这通常不是常规做法,因为直接操作内存地址可能会导致未定义行为。
  2. 赋值变量的地址:这是更常见的做法,使用取地址运算符(&)来获取变量的地址,并将其赋值给指针。
1
2
3
//定义ptr2并且赋值,指向var地址
int var = 10;
int *ptr2 = &var;

这里,ptr 被赋值为 var 的内存地址。

我们对上述代码进行图解:

https://cdn.llfc.club/fb06f3724d53b3d9f9157aa9e0062a9.png

ptr2指向var的地址,也就是ptr2存储的是var的地址0x2be3。因为ptr2也是对象,所以计算机也会为它开辟空间,ptr2自己的地址为0x3f2b.

所以指针的地址也可以用另一个指针变量存储

1
2
3
4
5
6
7
8
9
//定义ptr2并且赋值,指向var地址
int var = 10;
int *ptr2 = &var;
//ptr_address 存储ptr2的地址
int** ptr_address = &ptr2;
std::cout << "var 的地址为: " << &var << std::endl;
std::cout << "ptr2 存储的值为: " << ptr2 << std::endl;
std::cout << "ptr2 地址为: " << &ptr2 << std::endl;
std::cout << "ptr_address 的值为: " << ptr_address << std::endl;

上述代码打印结果为

1
2
3
4
var 的地址为: 0xad6a5ffc24
ptr2 存储的值为: 0xad6a5ffc24
ptr2 地址为: 0xad6a5ffc18
ptr_address 的值为: 0xad6a5ffc18

可以看到我们通过ptr_address 可以存储ptr2的地址, ptr_address存储的是指针的地址,所以它是一个指针的指针类型(int**),也叫做二级指针。

类型匹配

因为在声明语句中指针的类型实际上被用于指定它所指向的对象的类型,所以二者必须匹配。

如果指针指向了一个其他类型的对象,那么会产生错误。

1
2
3
4
5
6
7
8
9
double dval = 3.14;
//正确,初始值是double类型的对象的地址
double *pd = &dval;
//正确,初始值是指向double对象的指针
double *pd2 = pd;
//错误,pi的类型和pd类型不匹配
//int * pi = pd;
//错误,视图把double型对象的地址赋值给int型指针
//int * pi = &dval;

指针值

指针的值(即地址)应属下列4种状态之一:

1.指向一个对象。

2.指向紧邻对象所占空间的下一个位置。

3.空指针,意味着指针没有指向任何对象。

4.无效指针,也就是上述情况之外的其他值。

试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器并不负责检查此类错误,这一点和试图使用未经初始化的变量是一样的。

访问无效指针的后果无法预计,因此程序员必须清楚任意给定的指针是否有效。

尽管第2种和第3种形式的指针是有效的,但其使用同样受到限制。显然这些指针没有指向任何具体对象,所以试图访问此类指针(假定的)对象的行为不被允许。如果这样做了,后果也无法预计。

利用指针访问对象

我们可以利用*(解引用操作符)获取指针所指向的对象的数据,写法如下

1
数据变量 = *指针

https://cdn.llfc.club/1726532931384.jpg

我们看下这个例子

1
2
3
4
5
6
//访问对象
int ival = 42;
//p_int存放着ival的地址,或者说p_int是指向变量ival的指针
int * p_int = &ival;
//由符号*得到指针p所指向的对象,输出42
std::cout << *p_int ;

上面的例子通过*p_int获取p_int所指向的对象ival的值。输出42

利用指针修改对象

因为指针存储的是对象的地址,通过解引用获取对象的数据,我们也可以通过解引用修改对象的值,基本格式为

1
2
3
4
5
6
7
8
9
10
11
//访问对象
int ival = 42;
//p_int存放着ival的地址,或者说p_int是指向变量ival的指针
int * p_int = &ival;
//由符号*得到指针p所指向的对象,输出42
std::cout << *p_int << std::endl;
//由符号*得到指针p所指向的对象,即可经由p_int为变量ival赋值
*p_int = 0;
std::cout << * p_int << std::endl;
//打印ival的值
std::cout << "ival 的值为" << ival << std::endl;

注意

解引用操作仅适用于那些确实指向了某个对象的有效指针

1
2
3
4
//初始化一个空指针
int* empty_pointer = nullptr;
//打印空指针数据,非法
//std::cout << "空指针指向数据是 " << *empty_pointer << std::endl;

符号的多重含义

像&和*这样的符号,既能用作表达式里的运算符,也能作为声明的一部分出现,符号的上下文决定了符号的意义:

1
2
3
4
5
6
7
8
9
10
11
12
//符号的多重含义
int ival2 = 42;
//& 紧跟着类型名出现,因此是声明的一部分,r是一个引用
int &r = ival2;
//* 紧跟着类型名出现,因此是声明的一部分,p是一个指针
int *p;
// &出现在表达式中,是一个取地址符号
p = &ival2;
// * 出现在表达式中,是一个解引用符号
*p = ival2;
//& 是声明的一部分,*是一个解引用符号
int &r2 = *p;

空指针

空指针包含几种定义方式

1
2
3
4
5
6
7
8
9
10
11
//空指针定义方式
//C++11 最推荐方式
int *p1 = nullptr;
//直接将p2初始化为字面量0
int *p2 = 0;
//需要使用#include<cstdlib>
//等价于int * p3 = 0;
int *p3 = NULL;
std::cout << "p1: " << p1 << std::endl;
std::cout << "p2: " << p2 << std::endl;
std::cout << "p3: " << p3 << std::endl;

得到空指针最直接的办法就是用字面值nullptr来初始化指针,这也是C++11新标准刚刚引入的一种方法。

nullptr是一种特殊类型的字面值。

另一种办法就如对p2的定义一样,也可以通过将指针初始化为字面值0来生成空指针。

过去的程序还会用到一个名为NULL的预处理变量(preprocessor variable)来给指针赋值,这个变量在头文件cstdlib中定义,它的值就是0。

注意事项

把int变量直接赋给指针是错误的操作,即使int变量的值恰好等于0也不行

1
2
int zero = 0;
pi = zero;

地址变换

我们可以修改指针指向的地址,进而达到指向其他变量的目的.

指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中最重要的一点就是引用本身并非一个对象。

一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。

指针和它存放的地址之间就没有这种限制了。和其他任何变量(只要不是引用)一样,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象:

1
2
3
4
5
6
7
8
9
10
int ival3 = 42;
//pval3 被初始化,但没有指向任何对象
int *pval3 = 0;
//pval4被初始化,指向ival3的地址
int* pval4 = &ival3;
//将pval3的指向改为pval4的指向,二者同时指向ival3
pval3 = pval4;
std::cout <<"ival3 的地址为:" << &ival3 << std::endl;
std::cout <<"pval3 指向的地址为 " << pval3 << std::endl;
std::cout <<"pval4 指向的地址为 " << pval4 << std::endl;

运行上述程序可以看到输出

1
2
3
ival3 的地址为:0xf8329ff7a4
pval3 指向的地址为 0xf8329ff7a4
pval4 指向的地址为 0xf8329ff7a4

指针判空

有时候我们需要判断指针是否为空,可以通过if判断,if大家没学,此处仅作演示和理解。

在C++ 中0为false,非0为true,所以一个空指针通过if判断,是false, 非空指针为true

1
2
3
4
5
6
7
8
9
10
11
//空指针判断
int * empty_pointer2 = nullptr;
if(!empty_pointer){
std::cout << "empty_pointer is empty" << std::endl;
}

int test = 100;
int * normal_pointer2 = &test;
if(normal_pointer2){
std::cout << "normal pointer is not empty" << std::endl;
}

指针同样可以支持比较运算,判断相等(==),判断不等(!=)。

1
2
3
4
//判断指针是否相等
if(normal_pointer2 != empty_pointer){
std::cout << "normal_pointer 和 empty_pointer 不相等"<< std::endl;
}

万能指针

void*是一种特殊的指针类型,可用于存放任意对象的地址。一个void*指针存放着一个地址,这一点和其他指针类似。不同的是,我们对该地址中到底是个什么类型的对象并不了解:

1
2
3
4
5
6
//万能指针
double obj = 3.14, *obj_pd = &obj;
//void 可以存放任何类型的对象的地址
void * pv = &obj;
//pv 可以存储任意类型的地址
pv = obj_pd;

利用void*指针能做的事儿比较有限:

拿它和别的指针比较、作为函数的输入或输出,或者赋给另外一个void*指针。

不能直接操作void*指针所指的对象,因为我们并不知道这个对象到底是什么类型,也就无法确定能在这个对象上做哪些操作。

指向指针的指针

以指针为例,指针是内存中的对象,像其他对象一样也有自己的地址,因此允许把指针的地址再存放到另一个指针当中。通过*的个数可以区分指针的级别。也就是说,**表示指向指针的指针,***表示指向指针的指针的指针,以此类推:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//以指针为例,指针是内存中的对象,像其他对象一样也有自己的地址,
// 因此允许把指针的地址再存放到另一个指针当中。通过*的个数可以区分指针的级别。
// 也就是说,**表示指向指针的指针,***表示指向指针的指针的指针,以此类推:
int ival4= 1024;
// pival4指向了int型的数
int *pival4 = &ival4;
//ppi指向了一个int型的指针
int ** ppi = &pival4;

std::cout << "The value of ival\n"
<< "direct value: " << ival4 << "\n"
<< "indirect value: " << *pival4 << "\n"
<< "doubly indirect value : " << **ppi
<< std::endl;

该程序使用三种不同的方式输出了变量ival的值:第一种直接输出;第二种通过int型指针pi输出;第三种两次解引用ppi,取得ival的值。

https://cdn.llfc.club/1726537605315.jpg

指向指针的引用

引用本身不是一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对指针的引用:

1
2
3
4
5
6
7
8
9
10
11
//引用本身不是一个对象,因此不能定义指向引用的指针。
// 但指针是对象,所以存在对指针的引用
int init = 42;
// pinit是一个int型的指针
int *pinit;
// rpinit是一个对指针pinit的引用
int *& rpinit = pinit;
// rpinit引用了一个指针,因此给rpinit赋值&init就是令pinit指向init
rpinit = &init;
//解引用rpinit得到i,也就是p指向的对象,将init改为0
*rpinit = 0;

指针运算

指针可以进行算术运算,如递增(++)和递减(–),这些操作会改变指针所指向的内存地址。但是,这种操作仅限于指向数组元素的指针。

指针和数组

在C++中,数组名在表达式中通常被当作指向数组首元素的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
//指针可以进行算术运算,如递增(++)和递减(--),
// 这些操作会改变指针所指向的内存地址。但是,这种操作仅限于指向数组元素的指针。
int arr[5] = {1, 2, 3, 4, 5};
int *ptr_arr = arr;
std::cout << "ptr_arr is : " << ptr_arr << std::endl;
int firstElement = *ptr_arr;
std::cout << "firstElement is " << firstElement << std::endl;
// 递增指针
++ptr_arr; // ptr 现在指向 arr[1]
std::cout << "ptr_arr is : " << ptr_arr << std::endl;
// 访问新位置的值
int secondElement = *ptr_arr; // secondElement 等于 2
std::cout << "secondElement is " << secondElement;

上述程序输出

1
2
3
4
ptr_arr is : 0x3160fffa30
firstElement is 1
ptr_arr is : 0x3160fffa34
secondElement is 2

https://cdn.llfc.club/1726537913155.jpg

注意事项

  • 指针必须在使用前被初始化,否则它们可能包含垃圾值,导致未定义行为。
  • 指针运算(如递增和递减)仅适用于指向数组元素的指针。
  • 指针的解引用操作必须确保指针不是空指针(nullptr),否则会导致运行时错误。

零基础C++(5) 引用类型

Posted on 2024-09-16 | In 零基础C++

类型分类

1. 内置类型(基本类型)

内置类型是C++语言自带的基本数据类型,主要包括以下几种:

  • 整型(Integer Types)
    • int:标准整型,通常为4字节。
    • short:短整型,通常为2字节。
    • long:长整型,通常为4或8字节(取决于平台)。
    • long long:更长的整型,通常为8字节。
    • unsigned:无符号整型,不支持负数。
    • unsigned short、unsigned long、unsigned long long:对应的无符号版本。
  • 字符型(Character Types)
    • char:字符型,通常为1字节。
    • wchar_t:宽字符型,通常为2或4字节(用于表示Unicode字符)。
    • char16_t、char32_t:用于表示UTF-16和UTF-32编码的字符。
  • 浮点型(Floating Point Types)
    • float:单精度浮点型,通常为4字节。
    • double:双精度浮点型,通常为8字节。
    • long double:扩展精度浮点型,通常为8字节或16字节(取决于平台)。
  • 布尔型(Boolean Type)
    • bool:布尔型,表示真(true)或假(false)。

2. 复合类型

复合类型是由内置类型或其他复合类型组合而成的类型,主要包括以下几种:

  • 数组(Array)
    • 一组相同类型的数据元素,可以通过索引访问。
  • 结构体(Struct)
    • 一种用户定义的数据类型,可以包含不同类型的数据成员。
  • 联合体(Union)
    • 与结构体类似,但所有成员共享同一内存位置,只有一个成员可以在任何给定时间存储值。
  • 枚举(Enum)
    • 一种用户定义的类型,用于定义一组命名的整型常量。
  • 类(Class)
    • C++的面向对象编程特性,允许定义包含数据和成员函数的复杂数据类型。
  • 指针(Pointer)
    • 指向其他类型的内存地址,可以用来动态分配内存和实现复杂的数据结构。
  • 引用(Reference)
    • 对现有变量的别名,提供了对变量的另一种访问方式。

引用类型

定义

C++ 中的引用类型是一种复合类型,它是对另一个变量的别名。在C++中使用引用,可以让我们直接访问和操作另一个变量的内存地址,而不需要通过指针的解引用操作。引用在语法上比指针更简洁,且在许多情况下更安全。

变量内存演示

上面的定义很多人看起来很吃力,那我们回想一下变量的存储。

https://cdn.llfc.club/1726452128688.jpg

上面的图表示:

  • 定义一个变量a,并且初始化为100,编译器会为变量a开辟空间,绿色的是开辟的空间,存储100,这个空间的首地址为0x2be3,也就是变量a的地址。
  • 地址大家可以裂解为门牌号,我们可以通过门牌号找到绿色的家,a是绿色的家的名字,进而取出家里的物品,数据100可以理解为物品。
  • 执行b = a,将a赋值给b后,编译器又开辟了一块空间,存储100,这个空间的首地址为0x3f2b。 打个比方,我们又创建了一个家,家的名字是b,家里也存储了100这个物品,但是这个家的地址和a的不一样。

引用内存演示

https://cdn.llfc.club/1726453490297.jpg

  • 我们同样定义了一个变量a,并为它开辟空间,存储100,空间的首地址为0x2be3
  • 我们定义了一个引用b,它是a的别名,所以b的地址和a的地址都一样,都是0x2be3

从上述图形可以看出,引用是变量的别名。

写法

引用的基本写法

1
2
3
4
// 定义变量a
int a = 100;
// 定义引用b并且指向a, b就是a的别名
int &b = a;

大家能看到,在定义引用b的时候在int和b之间我们加了&符号, 这个int &表示的就是b是int类型的引用变量。

这里再提前告诉大家一个方法查看a和b地址是否相同, 当我们想输出a和b的地址的时候,只需要在a和b前加&即可输出他们的地址

1
2
std::cout << "a的地址为:" << &a << std::endl;
std::cout << "b的地址为: " << &b << std::endl;

上面输出

1
2
a的地址为:0x7ff65cae3000
b的地址为: 0x7ff65cae3000

可以看到a和b的地址相同。也证明了a和b是指向同一个地址空间。所以我们修改a的值,b的值也会变

1
2
3
4
5
6
7
8
9
10
11
12
// 定义变量a
int a = 100;
// 定义引用b并且指向a, b就是a的别名
int &b = a;
// 输出a和b的值
std::cout << "a的值:" << a << std::endl;
std::cout << "b的值: " << b << std::endl;

// a和b是指向同一个变量。所以我们修改a的值,b的值也会变
a = 200;
std::cout << "修改a的值后,a和b的值分别为:\n" << a << std::endl;
std::cout << b << std::endl;

程序输出如下

1
2
3
4
5
a的值:100
b的值: 100
修改a的值后,a和b的值分别为:
200
200

可以看到修改了a的值,b也跟着变化了,接下来我们修改b的值

1
2
3
4
// 修改b的值,a的值也会变
b = 300;
std::cout << "修改b的值后,a和b的值分别为:\n" << a << std::endl;
std::cout << b << std::endl;

程序输出

1
2
3
修改b的值后,a和b的值分别为:
300
300

可以看到b的值修改了,a的值也变化了

1
2
3
4
5
6
7
8
// 定义c,存储a的值
int c = a;
std::cout << "c的值:" << c << std::endl;
// 修改c的值
c = 400;
std::cout << "修改c的值后,c为:" << c << std::endl;
std::cout << "修改c的值后,a和b的值分别为:\n" << a << std::endl;
std::cout << b << std::endl;

程序输出如下

1
2
3
4
修改c的值后,c为:400
修改c的值后,a和b的值分别为:
300
300

可以看到c为a的副本,修改c不影响到a和b。

特性

  1. 必须初始化:引用在创建时必须被初始化,它必须指向某个已存在的对象。
  2. 一旦绑定,不可改变:引用一旦被初始化后,它将一直保持与其初始对象的绑定,不能改变为另一个对象的引用。
  3. 没有空引用:引用必须指向某个对象,不能存在空引用。

看下面的例子

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

int main() {
int a = 100;
int &b = a; // b是a的引用

std::cout << "a = " << a << ", b = " << b << std::endl; // 输出: a = 100, b = 100

b = 200; // 更改b的值也会更改a的值
std::cout << "a = " << a << ", b = " << b << std::endl; // 输出: a = 200, b = 200


// int c = 300;
// 表示修改b的值为c的值
// b = c;

return 0;
}

注意事项

  • 引用主要用于函数参数和返回值,以及类的成员变量等场景,以提供对原始数据的直接访问,从而提高程序的效率和可读性。
  • 引用可以是const的,这表示你不能通过引用来修改它所指向的对象的值。
  • 引用在内部实现上通常是通过指针来实现的,但它们在语法和用途上与指针有显著的不同。引用提供了更直观、更安全的访问方式。

左值引用和右值引用

在C++中,左值(lvalue)和右值(rvalue)是表达式的两种基本分类,它们决定了表达式的结果在内存中的位置和状态。左值通常指的是具有持久状态的对象,它们有明确的内存地址,可以被多次赋值。而右值通常是临时的、没有持久状态的值,它们通常没有内存地址,或者其内存地址在表达式结束后就变得无效。

C++11引入了右值引用(rvalue reference),用T&&表示,作为对左值引用(lvalue reference,用T&表示)的补充。这一特性极大地增强了C++的表达能力,特别是在资源管理和性能方面。

左值引用

左值引用是C++98就有的特性,它允许我们为已存在的对象创建一个别名。左值引用必须被初始化为一个左值,即一个具有持久状态的对象。

1
2
int a = 10;
int& b = a; // b是a的左值引用

右值引用

右值引用是C++11新增的特性,它允许我们为右值(即临时对象或即将被销毁的对象)创建一个引用。这样,我们就可以对右值进行更复杂的操作,比如移动语义(move semantics)。

1
2
3
4
5
6
7
int&& c = 20; // c是整数字面量20的右值引用(但这种情况不常见,通常用于函数参数或返回值)

std::string foo() {
return std::string("Hello, World!"); // 返回的临时字符串是一个右值
}

std::string &&d = foo(); // d是foo()返回的临时字符串的右值引用

但请注意,直接绑定一个右值到右值引用(如int&& c = 20;)并不是右值引用的主要用途。右值引用的主要用途是作为函数参数(实现移动语义)和返回值(允许链式调用等)。

移动语义和完美转发

右值引用的引入主要是为了支持移动语义(move semantics),它允许我们在对象被销毁前“窃取”其资源(如动态分配的内存、文件句柄等),而不是进行深拷贝。这可以显著提高性能,特别是在处理大型对象或容器时。

完美转发(perfect forwarding)是另一个与右值引用相关的概念,它允许我们将参数原封不动地传递给另一个函数,无论是左值还是右值。这通过模板和std::forward函数实现。

总结

  • 左值引用(T&)是C++98就有的特性,用于为已存在的对象创建别名。
  • 右值引用(T&&)是C++11新增的特性,用于为右值(即临时对象)创建引用,支持移动语义和完美转发等高级特性。
  • 右值引用的主要用途不是直接绑定到字面量或简单的右值表达式上,而是在函数参数和返回值中,以实现更高效的资源管理和更灵活的代码编写方式。

零基础C++(4) extern的作用

Posted on 2024-09-14 | In 零基础C++

重定义问题如何解决

产生原因

前文我们说过头文件只能用来做文件的声明,源文件用来做文件的实现。如果我们在头文件中定义变量会怎么样呢?

假设我们在global.h中定义了两个变量

1
2
3
4
5
6
#ifndef DAY05_EXTERN_GLOBAL_H
#define DAY05_EXTERN_GLOBAL_H
#include <string>
int global_age = 10;
std::string global_name = "llfc";
#endif //DAY05_EXTERN_GLOBAL_H

然后我们创建global.cpp包含global.h

1
#include "global.h"

然后我们在main.cpp中包含global.h

1
2
3
4
5
6
7
8
#include <iostream>
#include "global.h"
int main() {
std::cout << "Hello, World!" << std::endl;
std::cout << "globbal name is" << global_name << std::endl;
std::cout << "global age is " << global_age << std::endl;
return 0;
}

我们运行上述代码, 程序编译并未产生问题,但是链接产生问题了

1
2
3
4
[1/3] Building CXX object CMakeFiles/day05_extern.dir/global.cpp.obj
[2/3] Building CXX object CMakeFiles/day05_extern.dir/main.cpp.obj
[3/3] Linking CXX executable day05_extern.exe
FAILED: day05_extern.exe

再往下看

1
2
multiple definition of `global_age'; CMakeFiles/day05_extern.dir/main.cpp.obj: /global.h:8: first defined here
multiple definition of `global_name[abi:cxx11]'; CMakeFiles/day05_extern.dir/main.cpp.obj:/global.h:9: first defined here

上述报错的意思是在链接main.cpp.obj时发现global_age重定义,第一次定义在global.h这里。

同样链接mian.cpp.obj时发现global_name重定义,第一次定义在global.h。

因为global.h中定义了global_age,根据我们之前学习的预编译知识,只要是包含global.h的文件都会展开global.h,main.cpp展开了一次,global.cpp展开了一次

解决办法

extern的作用

在C++中,extern 关键字用于声明一个变量或函数是在另一个文件或同一个文件的其他位置定义的。这主要用于处理全局变量或函数声明,确保在多个源文件中能够正确地链接到这些全局变量或函数的定义。

解决办法

可以采用extern关键字声明变量,然后将变量的定义放在global.cpp中, 这样main.cpp包含global.h就只会展开声明。声明可以重复声明,不会有问题。

在global.h中用extern声明两个变量

1
2
3
4
5
6
#ifndef DAY05_EXTERN_GLOBAL_H
#define DAY05_EXTERN_GLOBAL_H
#include <string>
extern int global_age ;
extern std::string global_name ;
#endif //DAY05_EXTERN_GLOBAL_H

在global.cpp中定义这些变量

1
2
3
4
5
#include "global.h"
#include <string>
// 定义全局变量
std::string global_name = "John Doe";
int global_age = 30;

在main.cpp中包含global.h

1
2
3
4
5
6
7
8
#include <iostream>
#include "global.h"
int main() {
std::cout << "Hello, World!" << std::endl;
std::cout << "globbal name is" << global_name << std::endl;
std::cout << "global age is " << global_age << std::endl;
return 0;
}

再次编译,运行成功

1
2
3
Hello, World!
globbal name isJohn Doe
global age is 30

总结

  • 头文件只做变量的声明,不能做变量的定义

  • 头文件声明变量可以采用extern的方式

零基础C++(3) 认识头文件和源文件

Posted on 2024-09-08 | In 零基础C++

头文件和源文件

在C++中,头文件(.h 或 .hpp 文件)和源文件(.cpp 文件)是组织代码的重要部分,它们共同工作以构建程序。这种分离有助于模块化和代码重用,同时也使得编译过程更加高效。

头文件(.h 或 .hpp)

头文件主要用于声明(declarations),包括:

  • 类(class)的声明
  • 函数(functions)的原型(prototypes)
  • 模板(templates)的声明
  • 宏定义(#define)
  • 外部变量(extern variables)的声明
  • 内联函数(inline functions)

头文件通常包含预处理指令如 #ifndef、#define 和 #endif,这些指令用于防止头文件被重复包含(也称为“头文件保护”或“包含卫士”)。

示例头文件(example.h):

1
2
3
4
5
6
7
8
9
10
#ifndef EXAMPLE_H
#define EXAMPLE_H

class MyClass {
public:
MyClass(); // 构造函数声明
void myFunction(); // 成员函数声明
};

#endif

源文件(.cpp)

源文件包含实际的代码实现,即函数体、类的成员函数的实现等。源文件通常包括必要的头文件,以便编译器知道它们正在使用的函数、类等是如何声明的。

示例源文件(example.cpp):

1
2
3
4
5
6
7
8
9
10
#include "example.h"
#include <iostream>

MyClass::MyClass() {
// 构造函数实现
}

void MyClass::myFunction() {
std::cout << "Hello from MyClass::myFunction!" << std::endl;
}

编译过程

在编译C++程序时,编译器会首先处理源文件(.cpp 文件)。对于源文件中的每个 #include 指令,编译器都会查找并包含相应的头文件(.h 或 .hpp 文件)。然后,编译器将处理源文件中的所有实现代码,并将它们与从头文件中获取的声明进行匹配。

注意事项

  • 头文件应该只包含声明,源文件应该包含实现。
  • 使用头文件保护来避免头文件被重复包含。
  • 在大型项目中,合理组织头文件和源文件可以提高项目的可维护性和可扩展性。
  • 在编译时,确保所有的源文件都被编译,并且所有的头文件都被正确包含。

通过这种方式,C++程序的结构变得更加清晰和模块化,有利于多人协作和代码重用。

#pragma once 和 宏定义(如 #ifndef, #define, #endif)都是用来防止头文件被重复包含的机制,但它们在工作方式和使用场景上存在一些区别。

pragma once

pragma once作用

  • 工作方式:#pragma once 是一个非标准的但广泛支持的预处理指令,它告诉编译器该头文件在单个编译过程中只应被包含一次。编译器在第一次遇到 #pragma once 时会记住该文件名,并在后续的包含操作中忽略它。
  • 优点:简单、直观、易于使用。不需要生成唯一的宏名,减少了出错的可能性。
  • 缺点:不是 C++ 标准的一部分,尽管大多数现代编译器都支持它,但在某些旧的或特定的编译器中可能不受支持。
  • 使用场景:在支持 #pragma once 的编译器中,推荐使用它作为防止头文件重复包含的首选方法。

宏定义(#ifndef, #define, #endif)

  • 工作方式:通过宏定义(通常称为“包含卫士”或“头文件保护”)来防止头文件被重复包含。首先检查一个特定的宏是否已定义,如果没有定义,则定义它并包含头文件的其余部分。如果宏已经定义,则跳过头文件的其余部分。
  • 优点:是 C++ 标准的一部分,因此在所有 C++ 编译器中都是可用的。
  • 缺点:需要为每个头文件生成一个唯一的宏名,这可能会增加出错的机会(例如,如果两个头文件不小心使用了相同的宏名)。
  • 使用场景:在需要确保代码与所有 C++ 编译器兼容时,或者在不支持 #pragma once 的编译器中,使用宏定义来防止头文件重复包含。

总结

尽管 #pragma once 和 宏定义在功能上相似,但它们在实现方式和使用场景上有所不同。在大多数现代 C++ 项目中,推荐使用 #pragma once,因为它更简单、更直观,并且大多数现代编译器都支持它。然而,在需要确保与所有 C++ 编译器兼容的情况下,或者在不支持 #pragma once 的环境中,仍然需要使用宏定义来防止头文件被重复包含。

程序如何编译的

g++编译

g++是GNU(GNU’s Not Unix)项目开发的C++编译器,它是GCC(GNU Compiler Collection,GNU编译器套件)的一个重要组成部分。GCC是一个支持多种编程语言的编译器集合,而g++则专门用于编译C++代码。

  • 在使用g++编译C++程序时,可能需要安装GCC或g++编译器。在大多数Linux发行版和Unix系统中,GCC和g++通常作为标准软件包的一部分进行安装。在Windows系统中,则可能需要下载并安装MinGW或Cygwin等工具来提供GCC和g++的支持。

当使用 g++ 编译器编译 main.cpp 并希望包含相关的头文件时,你实际上不需要在编译命令中直接指定头文件。编译器会在编译过程中自动查找并包含你在 main.cpp 或其他已包含的头文件中通过 #include 指令指定的头文件。

然而,如果你的头文件位于非标准路径(即不在编译器的默认搜索路径中),你可能需要使用 -I 选项来指定额外的头文件搜索路径。

假设你的头文件 example.h 位于与 main.cpp 相同的目录下,或者位于编译器默认搜索的头文件路径中,你可以简单地使用以下命令来编译 main.cpp:

1
g++ main.cpp -o myprogram

这里,-o myprogram 指定了输出文件的名称(在这个例子中是 myprogram)。如果你没有指定 -o 选项,编译器通常会生成一个名为 a.out 的可执行文件(在 Unix-like 系统中)。

如果你的头文件位于不同的目录,比如 include 目录,并且 main.cpp 中包含了 #include "example.h",你需要使用 -I 选项来告诉编译器在哪里查找这个头文件:

1
g++ -Iinclude main.cpp -o myprogram

在这个例子中,-Iinclude 告诉编译器在 include 目录下查找头文件。注意,-I 选项后面紧跟的是目录名,而不是文件名。

如果你的项目包含多个源文件(.cpp 文件)和/或多个头文件,并且它们位于不同的目录中,

你可能还需要使用 -L 选项来指定库文件的搜索路径(如果你链接了外部库的话),以及使用 -l 选项来指定要链接的库名(去掉前缀 lib 和后缀 .so 或 .a)。但是,对于仅包含头文件和源文件的简单项目,通常只需要上述的编译命令即可。

CMake跨平台编译

CMake是一个跨平台的自动化构建系统,它使用CMakeLists.txt文件来描述构建过程。下面是一个CMake的基本写法示例,这将指导你如何编写一个简单的CMakeLists.txt文件来构建一个可执行文件。

示例:构建单个可执行文件

假设你有一个C++源文件main.cpp,你想用CMake来构建它。首先,你需要创建一个名为CMakeLists.txt的文件,通常这个文件位于你的项目根目录下。

1
2
3
4
5
6
7
8
9
# 设置CMake最小版本要求
cmake_minimum_required(VERSION 3.10)

# 设置项目名称和版本
project(MyProject VERSION 1.0)

# 添加一个可执行文件
# 语法:add_executable(目标名 源文件...)
add_executable(MyExecutable main.cpp)

在这个例子中:

  • cmake_minimum_required(VERSION 3.10):这行设置了CMake构建系统的最小版本要求。你需要确保你的CMake版本至少是3.10或更高。
  • project(MyProject VERSION 1.0):这行设置了项目的名称(MyProject)和版本(1.0)。这个命令也会创建一个变量${PROJECT_NAME}和${MyProject_VERSION},尽管直接使用MyProject_VERSION不是强制的,因为CMake通常建议使用${PROJECT_VERSION}来引用版本。
  • add_executable(MyExecutable main.cpp):这行定义了一个可执行文件目标。它告诉CMake你想将main.cpp编译成一个名为MyExecutable的可执行文件。构建时,CMake将自动找到适合你的平台的编译器和编译选项,并将main.cpp编译成可执行文件。

构建项目

在命令行中,首先进入包含CMakeLists.txt的目录,然后运行以下命令来配置CMake项目(这将生成一个构建系统,如Makefile):

1
2
3
mkdir build  # 创建一个名为build的目录(不是必须的,但推荐)
cd build
cmake .. # 使用上级目录中的CMakeLists.txt配置项目

配置完成后,你可以使用生成的构建系统来构建项目。如果你使用的是Makefile(大多数Unix-like系统),则可以运行:

1
make

这将编译你的项目,并生成可执行文件(在这个例子中是MyExecutable)。如果你是在Windows上,并且CMake配置的是生成Visual Studio项目文件,那么你需要使用Visual Studio来打开生成的项目文件并构建项目。

注意

  • CMake是一个非常强大的构建系统,支持多种编程语言、复杂的目标关系、库依赖、条件编译等高级功能。上面的例子仅展示了最基础的用法。
  • 总是建议使用一个单独的构建目录(如上面的build目录),这样就不会污染你的源代码目录。
  • CMake提供了大量的命令和变量,可以用来精确控制构建过程。建议查阅CMake的官方文档以了解更多信息。

项目难点和面试技巧

Posted on 2024-09-07 | In C++聊天项目

简介

项目第一季完结了,本文做一个整理,介绍面试将被问到的相关问题和技术难点,以及第二季将涉及的开发内容。

下面先介绍项目如何描述。

项目如何描述

按照HR搜索简历的方式,基本都是采用关键字搜索,所以要在简历中暴露项目中的技术亮点。

为了让面试官通过简历快速的了解项目和采用的技术,需在项目介绍时融入技术细节,让项目描述更饱满一点。

可增加个人业绩或者个人成长,让面试官了解到项目的意义等。

所以综上所述,简单做个总结,一个项目的描述需包含如下几点:

  • 项目描述
  • 项目亮点
  • 项目价值

项目描述

前端基于QT实现气泡聊天对话框,通过QListWidget实现好友列表,利用GridLayout和QPainter封装气泡聊天框,基于QT network模块封装http和tcp服务。支持添加好友,好友通信,聊天记录展示等功能,仿微信布局并使用qss优化界面

后端采用分布式设计,分为GateServer网关服务,多个ChatServer聊天服务,StatusServer状态服务以及VerifyServer验证服务。

各服务通过grpc通信,支持断线重连。GateServer网关对外采用http服务,负责处理用户登录和注册功能。登录时GateServer从StatusServer查询聊天服务达到负载均衡,ChatServer聊天服务采用asio实现tcp可靠长链接异步通信和转发, 采用多线程模式封装iocontext池提升并发性能。数据存储采用mysql服务,并基于mysqlconnector库封装连接池,同时封装redis连接池处理缓存数据,以及grpc连接池保证多服务并发访问。

经测试单服务器支持8000连接,多服务器分布部署可支持1W~2W活跃用户。

技术点

asio 网络库,grpc,Node.js,多线程,Redis, MySql,Qt 信号槽,网络编程,设计模式

项目意义

关于项目意义可结合自身讨论,比如项目解决了高并发场景下单个服务连接数吃紧的情况,提升了自己对并发和异步的认知和处理能力等。

考察点

1 如何利用asio实现的tcp服务

利用asio 的多线程模式,根据cpu核数封装连接池,每个连接池跑在独立线程,采用异步async_read和assync_write方式读写,通过消息回调完成数据收发。每个连接通过session类管理,底层绑定用户id和session关联,回调函数可根据session反向查找用户进行消息推送。通过tlv方式封装消息包防止粘包。通过心跳机制检测连接可用性。

2 如何保证服务高可用

  1. 故障检测与自动恢复:
    • 实施监控系统,实时检测服务的健康状况。
    • 配置自动重启或故障转移机制,确保在故障发生时能够迅速恢复服务。
  2. 分布式架构:
    • 采用微服务架构,将应用拆分为多个独立的服务,降低单个服务故障对整体系统的影响。
  3. 数据备份与恢复:
    • 定期备份数据,并进行恢复演练,确保在数据丢失或损坏时能够快速恢复。
  4. 多活部署:
    • 在不同地理位置部署多个活跃的数据中心,确保在某个数据中心发生故障时,其他数据中心可以继续提供服务。

3 为何封装Mysql连接池

​ 首先多个线程使用同一个mysql连接是不安全的,所以要为每个线程分配独立连接,而连接数不能随着线程数无线增加,所以考虑连接池,每个线程想要操作mysql的时候从连接池取出连接进行数据访问。Mysql连接池封装包括Mgr管理层和Dao数据访问层,Mgr管理层是单例模式,Dao层包含了一个连接池,采用生产者消费者模式管理可用连接,并且通过心跳定时访问mysql保活连接。

4 如何测试性能

​ 测试性能分为三个方面:

  • 压力测试,测试服务器连接上限

  • 测试一定连接数下,收发效率稳定性

  • 采用pingpong协议,收发效率稳定在10ms下,连接数上限

压力测试,看服务器性能,客户端初始多个线程定时间隔连接,单服务节点连接上限两w以上稳定连接,并未出现掉线情况

测试稳定性,单服务节点连接数1W情况下,收发稳定未出现丢包和短线,并且延迟稳定在10ms

保证10ms延迟情况下,增加连接数,测下连接数上限,这个看机器性能,8000~2W连接不等。

5 用到哪些设计模式和思想

  • 生产者消费者模式(涉及线程池)
  • 单例模式(网络管理和数据库管理类)
  • RAII思想(defer 回收连接)
  • 代理模式(数据库,redis等通过代理对接应用层调用,底层线程池隐藏技术细节)
  • MVC控制思想,客户端通过MVC三层结构设计
  • 线程分离,网络线程,数据处理线程,以及UI渲染线程分离
  • 心跳服务
  • 数据序列化压缩发送(Protobuf)
  • 队列解耦合,服务器采用发送队列保证异步顺序,通过接受队列缓存收到数据,通过逻辑队列处理数据。
  • 分布式设计,多服务通过grpc通信,支持断线重连
  • C++11 现代化技术,智能指针,模板类型推导,线程池等

6 描述线程池封装

​ 描述线程池封装,线程池采用C++ 11 风格编写,整体来说线程池通过单例封装,内部初始化N个线程,采用生产者消费者方式管理线程,包含任务队列,任务队列采用package_task打包存储,提供对外接口commit提交任务,采用bind语法实现任务提交在commit内部自行绑定,通过智能指针伪闭包方式保证任务生命周期。同时使用C++ 11 future特性,允许外部等待任务执行完成。

第二季待完成内容

待开发内容

  • 未实现资源服务器及断点续传

  • 客户端和聊天服务的心跳机制

  • 实现断线重连和踢人操作(未完全实现,目前仅支持客户端重新登录,服务器重新绑定连接,原连接未踢掉)

  • 未完整实现用户离线后数据清空操作

  • 客户端未实现信息编辑,头像上传等UI和逻辑

  • 未实现文本,图片,语音等信息传输

  • 未实现语音,视频实时通信

零基础C++(2) 作用域和存储空间

Posted on 2024-08-31 | In 零基础C++

变量计算

整型, 浮点,双精度等变量支持计算,所谓计算就是我们熟悉的 + ,-,*,/,%等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void calculate(){
//整形变量支持计算,所谓计算就是我们熟悉的 `+` ,`-`,`*`,`/`,`%`等
int a = 10;
int b = 20;
std::cout << "a + b = " << a+b << std::endl;
std::cout << "a - b = " << a-b << std::endl;
std::cout << "a * b = " << a*b << std::endl;
std::cout << "a / b = " << a/b << std::endl;
std::cout << "a % b = " << a%b << std::endl;
//浮点型变量支持计算,所谓计算就是我们熟悉的 `+` ,`-`,`*`,`/`等
float c = 10.5;
float d = 20.3;
std::cout << "c + d = " << c+d << std::endl;
std::cout << "c - d = " << c-d << std::endl;
std::cout << "c * d = " << c*d << std::endl;
std::cout << "c / d = " << c/d << std::endl;
//浮点型变量支持计算,所谓计算就是我们熟悉的 `+` ,`-`,`*`,`/`等
double e = 10.5;
double f = 20.3;
std::cout << "e + f = " << e+f << std::endl;
std::cout << "e - f = " << e-f << std::endl;
std::cout << "e * f = " << e*f << std::endl;
std::cout << "e / f = " << e/f << std::endl;
}

ASCII码表

计算机中字符是用ASCII码记录的,ASCII码为128字符(0-127)分配了唯一的数字编码,包括英文字母(大小写)、数字、标点符号和一些控制字符(如换行、回车等)。

https://cdn.llfc.club/ascii-1-1.png

比如字符‘A’ 对应十进制的65,字符‘a'对应十进制的97.

所以字符也可以计算

1
2
3
4
5
6
7
//字符变量支持计算,所谓计算就是我们熟悉的  `+` ,`-`,`*`,`/`等
char g = 'a';
char h = 'b';
std::cout << "g + h = " << (int)(g+h) << std::endl;
std::cout << "g - h = " << (int)(g-h) << std::endl;
std::cout << "g * h = " << (int)(g*h) << std::endl;
std::cout << "g / h = " << (int)(g/h) << std::endl;

类型划分

各种数据类型可以支持转换,double, float,int, char 这种C++给我们提供的基本类型也叫做内置类型。

我们以后学习了结构体struct和class等自定义类型后,这些类型叫做复合类型,引用和指针也属于复合类型。

变量大小

前文提过变量是存储在存储单元中,那么计算机为不同的变量分配的大小也不一样, 可以通过sizeof计算类型的大小

1
2
3
4
5
6
7
void sizeofnum(){
std::cout << "Size of char: " << sizeof(char) << " bytes\n";
std::cout << "Size of int: " << sizeof(int) << " bytes\n";
std::cout << "Size of float: " << sizeof(float) << " bytes\n";
std::cout << "Size of double: " << sizeof(double) << " bytes\n";
std::cout << "Size of long long: " << sizeof(long long) << " bytes\n";
}
1
2
3
4
5
Size of char: 1 bytes
Size of int: 4 bytes
Size of float: 4 bytes
Size of double: 8 bytes
Size of long long: 8 bytes

类型转换

这里仅看下内置类型转换,C++会自动推导转换, 复合类型之后再介绍。

1B = 8bit 最大能表示127

1
2
3
4
5
6
7
8
9
char a = 100; // 100 是int类型,然后赋值给 char, int是四字节,char 是1字节,如果数字过大会损失精度。
int b = a; // char 转换为 int类型,不会损失数据
double c = b; // int 转换为 double(算数转换)

std::cout << "a = " << static_cast<int>(a) << ", b = " << b << ", c = " << c << std::endl;

// 注意:下面的转换可能会导致数据丢失
unsigned int d = -1; // int 转换为 unsigned int,导致数据丢失
std::cout << "d = " << d << std::endl; // 输出一个很大的数

变量作用域

在C++中,变量作用域(Scope)指的是程序中变量可以被访问的代码区域。作用域决定了变量的生命周期和可见性。

我可以解释几种常见的变量作用域类型:

  1. 全局作用域:在函数外部声明的变量具有全局作用域。它们可以在程序的任何地方被访问,但通常建议在需要时才使用全局变量,因为它们可能导致代码难以理解和维护。
  2. 局部作用域:在函数内部、代码块(如if语句、for循环等)内部声明的变量具有局部作用域。它们只能在声明它们的代码块内被访问。一旦离开该代码块,这些变量就不再可见。
  3. 命名空间作用域:在命名空间中声明的变量(实际上是实体,如变量、函数等)具有命名空间作用域。它们只能在相应的命名空间内被直接访问,但可以通过使用命名空间的名称作为前缀来从外部访问。
  4. 类作用域:在类内部声明的成员变量和成员函数具有类作用域。成员变量和成员函数可以通过类的对象来访问,或者在某些情况下(如静态成员)可以通过类名直接访问。
  5. 块作用域:这是局部作用域的一个特例,指的是由大括号{}包围的代码块内部声明的变量。这些变量只能在该代码块内被访问。

全局作用域

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

// 全局变量,具有全局作用域
int globalVar = 42;

void func() {
std::cout << "Inside func: globalVar = " << globalVar << std::endl;
}

int main() {
std::cout << "Inside main: globalVar = " << globalVar << std::endl;
func(); // 访问全局变量
return 0;
}

局部作用域

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

void func() {
// 局部变量,具有局部作用域
int localVar = 10;
std::cout << "Inside func: localVar = " << localVar << std::endl;
// localVar 在这里之后就不再可见
}

int main() {
// 尝试访问 localVar 会导致编译错误
// std::cout << "localVar = " << localVar << std::endl; // 错误
func(); // 局部变量仅在func函数内部可见
return 0;
}

命名空间作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

// 定义一个命名空间
namespace MyNamespace {
// 命名空间内的变量,具有命名空间作用域
int namespaceVar = 20;

void printVar() {
std::cout << "Inside MyNamespace: namespaceVar = " << namespaceVar << std::endl;
}

int globalVar = 0;
}

namespace MyNamespace2 {
int globalVar = 0;
}

int main() {
// 使用命名空间前缀访问变量
std::cout << "Outside MyNamespace: namespaceVar = " << MyNamespace::namespaceVar << std::endl;
MyNamespace::printVar(); // 访问命名空间内的函数
return 0;
}

类作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

class MyClass {
public:
// 成员变量,具有类作用域
int classVar;

// 成员函数,也可以访问类作用域内的成员变量
void printVar() {
std::cout << "Inside MyClass: classVar = " << classVar << std::endl;
}
};

int main() {
MyClass obj;
obj.classVar = 30; // 通过对象访问成员变量
obj.printVar(); // 访问成员函数
// 尝试直接访问 classVar 会导致编译错误
// std::cout << "classVar = " << classVar << std::endl; // 错误
return 0;
}

块作用域

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

void func() {
{
// 块内局部变量,具有块作用域
int blockVar = 5;
std::cout << "Inside block: blockVar = " << blockVar << std::endl;
// blockVar 在这个代码块之后就不再可见
}
// 尝试访问 blockVar 会导致编译错误
// std::cout << "blockVar = " << blockVar << std::endl; // 错误
}

int main() {
func(); // 访问块作用域变量仅在func函数内部的代码块内有效
return 0;
}

存储区域

在C++中,内存存储通常可以大致分为几个区域,这些区域根据存储的数据类型、生命周期和作用域来划分。这些区域主要包括:

  1. 代码区(Code Segment/Text Segment):
    • 存储程序执行代码(即机器指令)的内存区域。这部分内存是共享的,只读的,且在程序执行期间不会改变。
    • 举例说明:当你编译一个C++程序时,所有的函数定义、控制结构等都会被转换成机器指令,并存储在代码区。
  2. 全局/静态存储区(Global/Static Storage Area):
    • 存储全局变量和静态变量的内存区域。这些变量在程序的整个运行期间都存在,但它们的可见性和生命周期取决于声明它们的作用域。
    • 举例说明:全局变量(在函数外部声明的变量)和静态变量(使用static关键字声明的变量,无论是在函数内部还是外部)都会存储在这个区域。
  3. 栈区(Stack Segment):
    • 存储局部变量、函数参数、返回地址等的内存区域。栈是一种后进先出(LIFO)的数据结构,用于存储函数调用和自动变量。
    • 举例说明:在函数内部声明的变量(不包括静态变量)通常存储在栈上。当函数被调用时,其参数和局部变量会被推入栈中;当函数返回时,这些变量会从栈中弹出,其占用的内存也随之释放。
  4. 堆区(Heap Segment):
    • 由程序员通过动态内存分配函数(如new和malloc)分配的内存区域。堆区的内存分配和释放是手动的,因此程序员需要负责管理内存,以避免内存泄漏或野指针等问题。
    • 举例说明:当你使用new操作符在C++中动态分配一个对象或数组时,分配的内存就来自堆区。同样,使用delete操作符可以释放堆区中的内存。
  5. 常量区(Constant Area):
    • 存储常量(如字符串常量、const修饰的全局变量等)的内存区域。这部分内存也是只读的,且通常在程序执行期间不会改变。
    • 举例说明:在C++中,使用双引号括起来的字符串字面量通常存储在常量区。此外,使用const关键字声明的全局变量,如果其值在编译时就已确定,也可能存储在常量区。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <cstring> // 用于strlen

// 全局变量,存储在全局/静态存储区
int globalVar = 10;

// 静态变量,也存储在全局/静态存储区,但仅在其声明的文件或函数内部可见
static int staticVar = 20;

void func() {
// 局部变量,存储在栈区
int localVar = 30;

// 静态局部变量,虽然声明在函数内部,但存储在全局/静态存储区,且只在第一次调用时初始化
static int staticLocalVar = 40;

std::cout << "Inside func:" << std::endl;
std::cout << "localVar = " << localVar << std::endl;
std::cout << "staticLocalVar = " << staticLocalVar << std::endl;

// 尝试通过动态内存分配在堆区分配内存
int* heapVar = new int(50);

std::cout << "heapVar = " << *heapVar << std::endl;

// 释放堆区内存(重要:实际使用中不要忘记释放不再使用的堆内存)
delete heapVar;
}

int main() {
// 访问全局变量
std::cout << "Inside main:" << std::endl;
std::cout << "globalVar = " << globalVar << std::endl;
std::cout << "staticVar = " << staticVar << std::endl; // 注意:staticVar在外部不可见(除非在同一个文件中或通过特殊方式)

// 调用函数,展示栈区和堆区的使用
func();

// 字符串常量通常存储在常量区,但直接访问其内存地址并不是标准C++的做法
// 这里我们仅通过指针来展示其存在
const char* strConst = "Hello, World!";
// 注意:不要尝试修改strConst指向的内容,因为它是只读的
std::cout << "strConst = " << strConst << std::endl;
// 尝试获取字符串常量的长度(这不会修改常量区的内容)
std::cout << "Length of strConst = " << strlen(strConst) << std::endl;

return 0;
}

在这个示例中,我使用了全局变量、静态变量、局部变量、静态局部变量以及通过new操作符在堆上分配的内存来展示不同内存区域的使用。同时,我也提到了字符串常量,但请注意,直接访问其内存地址并不是C++编程中的标准做法,因为字符串常量通常是只读的,并且其存储位置取决于编译器和操作系统的实现。

另外,请注意,我在func函数中分配了堆内存并通过delete操作符释放了它。这是管理堆内存时的一个重要实践,以避免内存泄漏。然而,在实际应用中,更复杂的内存管理策略(如智能指针)可能更为合适。

当您编译这个程序时,编译器会将main函数和func函数的代码转换成机器指令,并将这些指令存储在可执行文件的代码区中(尽管实际上是在磁盘上的可执行文件中,但在程序运行时,操作系统会将这些指令加载到内存的代码区中)。然后,当您运行这个程序时,CPU会从内存的代码区中读取这些指令并执行它们。

程序编译过程

C++程序的编译过程是一个相对复杂但有序的过程,它涉及将高级语言(C++)代码转换为机器可以执行的低级指令。在这个过程中,通常会生成几个中间文件,包括.i(预处理文件)、.s(汇编文件)和.o(目标文件或对象文件)。下面是这个过程的详细解释:

1. 预处理(Preprocessing)

  • 输入:C++源代码文件(通常以.cpp或.cxx为后缀)。
  • 处理:预处理器(通常是cpp)读取源代码文件,并对其进行宏展开、条件编译、文件包含(#include)等处理。
  • 输出:生成预处理后的文件,通常具有.i后缀(尽管这个步骤可能不是所有编译器都会自动生成.i文件,或者可能需要特定的编译器选项来生成)。

2. 编译(Compilation)

  • 输入:预处理后的文件(如果有的话,否则直接是源代码文件)。
  • 处理:编译器(如g++、clang++等)将预处理后的文件或源代码文件转换为汇编语言代码。这个步骤是编译过程的核心,它执行词法分析、语法分析、语义分析、中间代码生成、代码优化等任务。
  • 输出:生成汇编文件,通常具有.s或.asm后缀。

3. 汇编(Assembly)

  • 输入:汇编文件。
  • 处理:汇编器(如as、gas等)将汇编语言代码转换为机器语言指令(即目标代码),但这些指令仍然是针对特定架构的,并且尚未被链接成可执行文件。
  • 输出:生成目标文件(或对象文件),通常具有.o、.obj或.out后缀。

4. 链接(Linking)

  • 输入:一个或多个目标文件,以及可能需要的库文件(如C++标准库)。
  • 处理:链接器(如ld、lld等)将目标文件和库文件合并成一个可执行文件或库文件。在这个过程中,链接器会解决外部符号引用(即函数和变量的调用),并将它们链接到正确的地址。
  • 输出:生成可执行文件(在Unix-like系统中通常是.out、.exe或没有特定后缀,在Windows系统中是.exe)。

我们将代码的CMakeList中设置编译选项,保存临时文件

1
2
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -save-temps=obj")
set(CMAKE_C_FLAGS "${CMAKE_CXX_FLAGS} -save-temps=obj")

点击Clion中的build,可以看到目录中生成了临时文件,包括

.i 文件表示预处理文件

.s(汇编文件)

.o(目标文件或对象文件)

https://cdn.llfc.club/1725082193757.jpg

总结

  • .i文件是预处理后的文件,包含了所有宏展开、条件编译和文件包含的结果。
  • .s文件是汇编文件,包含了将C++代码转换为汇编语言后的结果。
  • .o文件是目标文件或对象文件,包含了汇编器生成的机器语言指令,但尚未被链接成可执行文件。

这些文件在编译过程中扮演了重要的角色,帮助开发者理解和调试代码,同时也是编译链中不可或缺的一部分。不过,值得注意的是,并非所有编译器都会默认生成.i和.s文件,这可能需要特定的编译器选项来启用。

赞赏

感谢支持

https://cdn.llfc.club/dashang.jpg

零基础C++(1) 变量和输出

Posted on 2024-08-31 | In 零基础C++

变量

变量定义

在C++中,变量是用来存储数据值的一种实体。每个变量都有一个类型,这个类型决定了变量可以存储的数据的种类以及变量在内存中所占的空间大小。

通俗例子

我们家中会有收纳柜,收纳柜存储的是我们的物品。物品就相当于数据,收纳柜就相当于变量,不同的柜子有不同的类型,有的装内衣,有的装袜子。

https://cdn.llfc.club/%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A1%E6%88%AA%E5%9B%BE_17245418515191.png

装内衣的柜子一般不会装螺丝刀,一个柜子在定义好用来做什么,它存储的内容就确定了。

补充知识

计算机中数据是按照二进制存储的,一个字节占8bit, bit就是位的意思,比如数字2会转化为二进制00000010, 然后将这个00000010放到计算机为我们分配好的存储单元里,这个存储单元本身还有一个地址,假设存储单元的地址为5,转化为二进制就是00000101.

https://cdn.llfc.club/%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A1%E6%88%AA%E5%9B%BE_17245430325039.png

当我们想要取出数据00000010时,需要先访问地址00000101找到存储单元,然后取出存储单元存储的数据。

再次理解以下,存储单元可以理解为一个变量,存储了数据00000010,变量的地址为00000101。理解这个,之后我们会介绍指针的概念。

变量的声明与初始化

在C++中,你首先需要声明一个变量,然后(可选地)可以初始化它。声明变量时,你需要指定变量的类型和名称。

1
2
3
4
5
6
7
8
// 声明一个整型变量age,未初始化
int age;
// 声明并初始化一个整型变量height
int height = 175;
// 声明并初始化一个双精度浮点型变量weight
double weight = 65.5;
// 声明并初始化一个字符型变量gender
char gender = 'M';

变量命名规则

在C++中,变量名可以包含字母、数字和下划线(_),但不能以数字开头。此外,C++是大小写敏感的,因此age和Age被视为两个不同的变量。

变量类型

C++支持多种基本数据类型,包括整型(int、short、long、long long)、浮点型(float、double、long double)、字符型(char)、布尔型(bool)等。此外,C++还支持枚举(enum)、结构体(struct)、联合体(union)和类(class)等复合数据类型。

示例:使用变量

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

int main() {
//初始化变量a和b
int a = 5, b = 10;
//a+b的值赋值给sum
int sum = a + b;
//输出求和的结果
std::cout << "The sum of " << a << " and " << b << " is " << sum << std::endl;
return 0;
}

在这个示例中,我们声明了三个整型变量a、b和sum,并将a和b的值分别初始化为5和10。然后,我们计算a和b的和,并将结果存储在变量sum中。最后,我们使用std::cout输出计算结果。

1
The sum of 5 and 10 is 15

输出运算符

在C++中,输出通常是通过标准输出流(std::cout)来完成的。std::cout 是C++标准库中的一个对象,它代表标准输出设备,通常是屏幕。为了使用 std::cout 来输出数据,你需要包含头文件 <iostream>。

下面是一个简单的C++程序示例,展示了如何使用 std::cout 来输出文本和数字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream> // 包含标准输入输出流库

int main() {
// 输出字符串
std::cout << "Hello, World!" << std::endl;

// 输出数字
int number = 42;
std::cout << "The number is: " << number << std::endl;

// 输出浮点数
double pi = 3.14159;
std::cout << "The value of pi is approximately: " << pi << std::endl;

// 输出字符
char ch = 'A';
std::cout << "The character is: " << ch << std::endl;

// 输出布尔值(C++11及以后)
bool truth = true;
std::cout << std::boolalpha; // 启用布尔值的true/false输出
std::cout << "The truth is: " << truth << std::endl;

return 0;
}

在这个例子中,<< 是插入运算符,用于将右侧的数据发送到左侧的流对象(在这个例子中是 std::cout)。std::endl 是一个操纵符,用于在输出后插入换行符,并刷新输出缓冲区,确保立即在屏幕上显示输出。

1
2
3
4
5
Hello, World!
The number is: 42
The value of pi is approximately: 3.14159
The character is: A
The truth is: true

理解即可

注意,为了输出布尔值 true 和 false 而不是整数 1 和 0,我们使用了 std::boolalpha 操纵符。这是C++11及以后版本中引入的,用于控制布尔值的输出格式。

此外,C++还支持格式化输出,但这通常涉及更复杂的语法,如使用 std::iomanip 头文件中定义的操纵符(如 std::setw 和 std::setprecision或使用C++20引入的新特性(如格式化库 std::format,尽管这不是通过 std::cout 直接使用的,而是用于生成格式化字符串)。

练习

定义初始化一个变量pai_val数值为 3.14, 初始化一个int类型的变量int_val变量值为5,分别输出这两个变量的值。

初始化一个bool变量数值为-100,输出bool变量的值,看看是true还是false。

赞赏

感谢支持

https://cdn.llfc.club/dashang.jpg

C++ 全栈聊天项目(10) 使用iocontext连接池提高并发

Posted on 2024-08-31 | In C++聊天项目

VerifyServer增加redis

我们为了让验证码有一个过期时间,可以利用redis实现,在Verify文件夹用npm安装redis服务。

1
npm install ioredis

完善config.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"email": {
"user": "secondtonone1@163.com",
"pass": "CRWTAZOSNCWDDQQTllfc"
},

"mysql": {
"host": "81.68.86.146",
"port": 3308,
"passwd": "123456"
},

"redis":{
"host": "81.68.86.146",
"port": 6380,
"passwd": "123456"
}
}

服务里添加redis模块,封装redis操作在redis.js中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

const config_module = require('./config')
const Redis = require("ioredis");

// 创建Redis客户端实例
const RedisCli = new Redis({
host: config_module.redis_host, // Redis服务器主机名
port: config_module.redis_port, // Redis服务器端口号
password: config_module.redis_passwd, // Redis密码
});


/**
* 监听错误信息
*/
RedisCli.on("error", function (err) {
console.log("RedisCli connect error");
RedisCli.quit();
});

/**
* 根据key获取value
* @param {*} key
* @returns
*/
async function GetRedis(key) {

try{
const result = await RedisCli.get(key)
if(result === null){
console.log('result:','<'+result+'>', 'This key cannot be find...')
return null
}
console.log('Result:','<'+result+'>','Get key success!...');
return result
}catch(error){
console.log('GetRedis error is', error);
return null
}

}

/**
* 根据key查询redis中是否存在key
* @param {*} key
* @returns
*/
async function QueryRedis(key) {
try{
const result = await RedisCli.exists(key)
// 判断该值是否为空 如果为空返回null
if (result === 0) {
console.log('result:<','<'+result+'>','This key is null...');
return null
}
console.log('Result:','<'+result+'>','With this value!...');
return result
}catch(error){
console.log('QueryRedis error is', error);
return null
}

}

/**
* 设置key和value,并过期时间
* @param {*} key
* @param {*} value
* @param {*} exptime
* @returns
*/
async function SetRedisExpire(key,value, exptime){
try{
// 设置键和值
await RedisCli.set(key,value)
// 设置过期时间(以秒为单位)
await RedisCli.expire(key, exptime);
return true;
}catch(error){
console.log('SetRedisExpire error is', error);
return false;
}
}

/**
* 退出函数
*/
function Quit(){
RedisCli.quit();
}

module.exports = {GetRedis, QueryRedis, Quit, SetRedisExpire,}

server.js中包含redis.js

1
const redis_module = require('./redis')

获取验证码之前可以先查询redis,如果没查到就生成uid并且写入redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
async function GetVarifyCode(call, callback) {
console.log("email is ", call.request.email)
try{
let query_res = await redis_module.GetRedis(const_module.code_prefix+call.request.email);
console.log("query_res is ", query_res)
if(query_res == null){

}
let uniqueId = query_res;
if(query_res ==null){
uniqueId = uuidv4();
if (uniqueId.length > 4) {
uniqueId = uniqueId.substring(0, 4);
}
let bres = await redis_module.SetRedisExpire(const_module.code_prefix+call.request.email, uniqueId,600)
if(!bres){
callback(null, { email: call.request.email,
error:const_module.Errors.RedisErr
});
return;
}
}

console.log("uniqueId is ", uniqueId)
let text_str = '您的验证码为'+ uniqueId +'请三分钟内完成注册'
//发送邮件
let mailOptions = {
from: 'secondtonone1@163.com',
to: call.request.email,
subject: '验证码',
text: text_str,
};

let send_res = await emailModule.SendMail(mailOptions);
console.log("send res is ", send_res)

callback(null, { email: call.request.email,
error:const_module.Errors.Success
});


}catch(error){
console.log("catch error is ", error)

callback(null, { email: call.request.email,
error:const_module.Errors.Exception
});
}

}

验证服务联调

开启VerifyServer和GateServer,再启动客户端,点击获取验证码,客户端就会发送请求给GateServer,GateServer再调用内部服务VerifyServer。最后将请求返回客户端,完成了验证码发送的流程。

如果10分钟之内多次请求,因为验证码被缓存在redis中,所以会被复用返回给客户端。

https://cdn.llfc.club/1711079058273.jpg

看起来客户端收到服务器的回复了,我们去邮箱看看是否收到验证码

https://cdn.llfc.club/1711080431619.jpg

确实收到了验证码。好的多服务调用实现了,大家可以把这个功能理解下,接下来去实现注册逻辑。

C++ 全栈聊天项目(1)架构概述和登录界面

Posted on 2024-08-31 | In C++聊天项目

简介

本项目为C++全栈聊天项目实战,包括PC端QT界面编程,asio异步服务器设计,beast网络库搭建http网关,nodejs搭建验证服务,各服务间用grpc通信,server和client用asio通信等,也包括用户信息的录入等。实现跨平台设计,先设计windows的server,之后再考虑移植到windows中。较为全面的展示C++ 在实际项目中的应用,可作为项目实践学习案例,也可写在简历中。

架构设计

一个概要的结构设计如下图
https://cdn.llfc.club/1709009717000.jpg

  1. GateServer为网关服务,主要应对客户端的连接和注册请求,因为服务器是是分布式,所以GateServer收到用户连接请求后会查询状态服务选择一个负载较小的Server地址给客户端,客户端拿着这个地址直接和Server通信建立长连接。
  2. 当用户注册时会发送给GateServer, GateServer调用VarifyServer验证注册的合理性并发送验证码给客户端,客户端拿着这个验证码去GateServer注册即可。
  3. StatusServer, ServerA, ServerB都可以直接访问Redis和Mysql服务。

创建应用

我们先创建客户端的登录界面,先用qt创建qt application widgets

https://cdn.llfc.club/1709012628800.jpg

项目名称叫做llfcchat,位置大家自己选择。

接下来一路同意,最后生成项目。

https://cdn.llfc.club/1709014472288.jpg

为了增加项目可读性,我们增加注释模板

选择“工具”->“选项”,再次选择“文本编辑器”->“片段”->“添加”,按照下面的模板编排

1
2
3
4
5
6
7
8
9
/******************************************************************************
*
* @file %{CurrentDocument:FileName}
* @brief XXXX Function
*
* @author 恋恋风辰
* @date %{CurrentDate:yyyy\/MM\/dd}
* @history
*****************************************************************************/

如下图
https://cdn.llfc.club/1709014829278.jpg

以后输入header custom就可以弹出注释模板了.

修改mainwindow.ui属性,长宽改为300*500
https://cdn.llfc.club/1709017541569.jpg

将window title 改为llfcchat

大家自己找个icon.ico文件放到项目根目录,或者用我的也行,然后在项目pro里添加输出目录文件和icon图标

1
2
RC_ICONS = icon.ico
DESTDIR = ./bin

将图片资源添加ice.png添加到文件夹res里,然后右键项目选择添加新文件,选择qt resource files, 添加qt的资源文件,名字设置为rc。

添加成功后邮件rc.qrc选择添加现有资源文件,

选择res文件夹下的ice.png,这样ice.png就导入项目工程了。

创建登录界面

右键项目,选择创建,点击设计师界面类

https://cdn.llfc.club/1709018322347.jpg

选择 dialog without buttons

https://cdn.llfc.club/1709018398183.jpg

创建的名字就叫做LoginDialog

https://cdn.llfc.club/1709018587338.jpg

将LoginDialog.ui修改为如下布局

https://cdn.llfc.club/1709019305029.jpg

在mainwindow.h中添加LoginDialog指针成员,然后在构造函数将LoginDialog设置为中心部件

1
2
3
4
5
6
7
8
9
10
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow)
{
ui->setupUi(this);
//创建一个CentralWidget, 并将其设置为MainWindow的中心部件
_login_dlg = new LoginDialog();
setCentralWidget(_login_dlg);
_login_dlg->show();
}

创建注册界面

注册界面创建方式和登录界面类似,我们创建的界面如下:

https://cdn.llfc.club/1709030381543.jpg

创建好界面后接下来在LoginDialog类声明里添加信号切换注册界面

1
2
signals:
void switchRegister();

在LoginDialog的构造函数里连接按钮点击事件

1
connect(ui->reg_btn, &QPushButton::clicked, this, &LoginDialog::switchRegister);

按钮点击后LoginDialog发出switchRegister信号,该信号发送给MainWindow用来切换界面。

我们在MainWindow里声明注册类变量

1
2
private:
RegisterDialog* _reg_dlg;

在其构造函数中添加注册类对象的初始化以及连接switchRegister信号

1
2
3
4
5
//创建和注册消息的链接
connect(_login_dlg, &LoginDialog::switchRegister,
this, &MainWindow::SlotSwitchReg);

_reg_dlg = new RegisterDialog();

接下来实现槽函数SlotSwitchReg

1
2
3
4
5
void MainWindow::SlotSwitchReg(){
setCentralWidget(_reg_dlg);
_login_dlg->hide();
_reg_dlg->show();
}

这样启动程序主界面优先显示登录界面,点击注册后跳转到注册界面

优化样式

我们在项目根目录下创建style文件夹,在文件夹里创建stylesheet.qss文件,然后在qt项目中的rc.qrc右键添加现有文件,选择stylesheet.qss,这样qss就被导入到项目中了。

在主程序启动后加载qss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main(int argc, char *argv[])
{
QApplication a(argc, argv);

QFile qss(":/style/stylesheet.qss");

if( qss.open(QFile::ReadOnly))
{
qDebug("open success");
QString style = QLatin1String(qss.readAll());
a.setStyleSheet(style);
qss.close();
}else{
qDebug("Open failed");
}

MainWindow w;
w.show();

return a.exec();
}

然后我们写qss样式美化界面

1
2
3
QDialog#LoginDialog{
background-color:rgb(255,255,255)
}

主界面有一道灰色的是toolbar造成的,去mainwindow.ui里把那个toolbar删了就行了。

<1…678…41>

401 posts
18 categories
21 tags
RSS
GitHub ZhiHu
© 2026 恋恋风辰 本站总访问量次 | 本站访客数人
Powered by Hexo
|
Theme — NexT.Muse v5.1.3