C++ 左右值、左右引用、万能引用、引用折叠、完美转发详解

前言

本文介绍C++11引入的完美转发实现,其本质通过万能引用+引用折叠+std::static_cast进行实现。
本文将详细介绍以下内容:

  • 左值、范左值、右值、将亡值、纯右值等基本概念;
  • 左值引用、右值引用等基本概念
  • 万能引用、引用折叠
  • 完美转发
  • 完美转发的实现原理

左值与右值

左值与右值在C++11标准发布后有了很大的变化,在C++11之前似乎可以简单的将左值与右值理解为等式左右两边的值(右值是只能出现在等式右边的值,而左值则是可以出现在等式两边),这样的理解在C++11之前不会有什么大问题,但是在C++11标准之后,其引入移动语义之后,再这样理解就大有问题了。
由于cppreference官网的定义非常详细,但是想要全部记一下来并不容易。这里进行一些简单的说明。

左值

cppreference中对左值(lvalue)的定义非常详细,但是其定义又过于详细,很难记住。这里可以将左值简单的理解为可以通过&获得地址的值。
比较特殊的几个左值:

  • 字符串常量值,例如"Hello World"
  • 内嵌的左自增或者左自减操作,例如--a, --a
  • 通过static_cast转换为左值引用,例如static_cast<int&>(a)
  • 第二个运算数为左值的逗号运算符返回值为左值;
  • 左值数组的下标访问,所谓左值数组指的是有标识指定的数组,例如:int a[] = {0};,其中a就是左值数组,那么a[0]就是一个左值。

不是左值的特殊例子:

  • 类变量的成员函数不是左值;
  • 枚举不是左值。

纯右值

纯右值(prvalue, pure rvalue)首先不是一个左值,即不能通过&获取地址。一种常见的纯右值为字面量(字符串字面量除外,其为左值),同时内嵌的算术运算法、逻辑运算法以及比较运算符返回值均为纯右值。
特殊的几个纯右值:

  • 类变量的成员函数(静态方法往往不会被认为属于类变量,静态方法属于左值);
  • 枚举;
  • 第二个运算数为右值的逗号运算符返回值为右值;
  • lamda表达式;
  • this指针(通常的指针是可以通过地址符获取指针变量的地址的,但是this不行,this更像是一个地址的别名,编译器在处理的时候会把this换成是一个地址字面量)。
  • 内嵌的右自增或右自减操作,例如a++, a--
  • 通过static_cast转换成非引用类型,例如static_cast<int>(a)

通过上面的例子可以将纯右值简单的理解为变量值的拷贝,且纯右值常常用于变量值的初始化。

将亡值

将亡值也叫xvalue(expiring value)其意思是指生命周期将要结束的值,该值与纯右值有个相同的特征,不能通过&获取地址。
几个常见的将亡值:

  • 右值变量的非静态成员变量;
  • 第二个运算数为将亡值的逗号运算符返回值为左值;
  • 返回值为右值引用的函数返回值;
  • 通过static_cast转换成右值引用类型,例如static_cast<int&&>(a),那么std::move当然也是将亡值;
  • return x中的x是将亡值;
  • 右值数组的下标访问,右值数组指的是没有标识符的数组,例如:int[3]{1, 2, 3},自然int[3]{1, 2, 3}[0]是一个将亡值。

注意:右值数组的下标访问为将亡值这是标准的定义,但是实际中需要确保自己编译器是否完全支持C++11的特性。例如MinGWgcc 8.1.0会将其认定为左值。

将亡值的资源往往可以重复利用。
注意:三元运算符的左右值确定规则是通过其返回值来决定,而其返回值类型确定较为复杂,准备之后介绍std::common_type时进行详细的解释,conditionnal operator cppreference。

范左值

范左值(generalized lvalue)指的是左值和将亡值,这类值往往是对象或者函数的标识符。

右值

右值(rvalue)指的是纯右值和将亡值。

常见混淆

右值引用是右值吗?
左值引用是左值吗?

不论右值引用还是左值引用其都是左值,都可以通过取地址运算获取地址。

引用

左值引用

为什么需要左值引用?

在没有左值引用之前,当一个函数对形参进行修改同时希望实参也进行修改的话,那么就必须要用到指针了,例如学习C语言区分形参和实参过程中的交换程序:

void badSwapTwoInt(int a, int b)
{int temp = a;a = b;b = a;
}

上面的代码并不能够交换两个变量的值,原因在于形参是对实参的拷贝,若要进行交换则必须使用指针(在没有左值引用之前):

void goodSwapTwoInt(int *a, int *b)
{int temp = *a;*a = *b;*b = temp;
}	

这样的写法很不友好,每次需要读取值或者改变值的时候还需要使用*进行解引用,很是麻烦,同时指针对于初学者很不友好(虽然即使没有指针,C++对初学者也很不友好),有没有一种简单的方法,能够让两个变量进行绑定,其中一个改变,另一个也跟着变?
于是引用便产生了。上述代码的左值引用版本:

void goodSwapTwoIntByRef(int &a, int &b)
{int temp = a;a = b;b = temp;
}

上述的代码和badSwapTwoInt相比只是在形参前面加了&符号,也就是表示左值引用,只要做了这样的更改后,形参发生变化实参也就会发生变化(如果实参发生变化,形参也会变化,但是在这里并不能体现)。
于是左值引用便出现了,其语义更像是为变量取一个别名,引用的值始终会和绑定的变量值保持一致(任意一个发生变化,另一个均会同时变化)。

左值引用如何实现?

指针常量(constant pointer),一个指针,当其指定到一个地址后,其指向不能发生改变,但是可以修改指向的数据,也就是对于上面的swap编译器实际上为我们做了如下的操作:

void goodSwapTwoIntAct(int* const a, int* const b)
{int temp = *a;*a = *b;*b = temp;
}

在调用的时候编译器会帮我们取地址与解引用,这也是为什么上文中我用了“同时”这个词语,因为本来就是同一块地址,当然是同时进行变化,无所谓先后。

右值引用

为什么需要右值引用?

这就得提到数据的移动与复制了。在C++11之前是没有移动语义的,只有复制语义(复制构造器和赋值操作符)。对于成员变量含有指针的数据类型,我们往往需要自己实现复制构造器与赋值操作符,以保证数据能够成功被拷贝(通常所说的深拷贝),而不只是让指针指向同一块内存(通常说的浅拷贝)即可。
这么看似乎没有什么问题,但是当将这一切带入到函数传参过程中,一切就变得微妙起来了。当函数的形参是一个非指针或者左值引用类型时,那么函数每进行一次调用都会进行一次复制构造,那么内存中就会有同一份数据的多份样本,实际上,很多情况下我们只需要内存中有一份数据即可,这在形参被const修饰是表现得尤为明显:

// when calling function a, and reaching c, there are three copies of data (a, b, c) in memory.
void c(const BigData c) { // do something }
void b(const BigData b) { C(b); }
void a(const BigData a) { B(a); }

那用指针或者左值引用不就好了?
嗯,不错,用指针或者左值引用当然可以解决部分问题,例如上面的代码改成:

// there are only one data (a, b and c are the same data) in memory.
void c(const BigData &c) { // do something }
void b(const BigData &b) { C(b); }
void a(const BigData &a) { B(a); }

但是还是有问题,例如现在调用a函数:

a(BigData{"a.txt"}); // read big data from data.txt

上面的代码也能实现内存中只有一份数据,但是却不能在a, b, c中修改形参,想要修改则必须去掉形参中的const,但是去掉const之后就不能够绑定右值了,而右值引用的出现解决了这个问题。
右值引用可以用来绑定右值,以及用来实现移动语义,为此我们先介绍移动语义。

移动语义

所谓移动语义指的是先进行浅拷贝,然后将指针指向空。
根据移动语义的定义,我们可以知道我们如果要保证每一次参数的传递后内存中均只有一份数据,那么我们只需要每一次传参的时候通过移动语义构造参数即可。而移动语义的实现则依赖于右值引用以及移动构造器,移动赋值操作符。
右值引用,非模板参数使用&&指明:

st::string &&rRef = "HelloWorld";

移动构造器:

ClassType(ClassType &&other);

移动赋值操作符(通常返回值为左值引用):

ClassType& operator=(ClassType &&other);

C++11提供了默认的移动构造器与移动赋值操作符,其表现行为同复制构造器和赋值操作符。因此在大多数情况下,我们需要自己动手实现,以保证其符合移动语义。
下面来通过std::string给出几个移动语义的效果:

  1. 在初始化时使用右值进行初始化,此时会调用移动构造器:
    std::string value = std::string("HelloWorld"); // std::string("HelloWorld") is rvalue, this will trigger move constructor
    
    上面的代码在没有移动语义的时候,其先会构造出来一个临时对象,再通过复制构造器复制一份到value;有了移动语义以后,右值可以绑定到右值引用上,上述过程变成:构造临时对象,通过移动构造器将临时对象移动value,与之前相比少了一次复制操作,这在对象的指针指向大量数据时能够有效地提升性能。
  2. 非初始化阶段通过等号赋值,同时等号右侧为右值,则触发移动赋值运算符:
    std::string value;
    value = std::string("HelloWorld"); // std::string("HelloWorld") is rvalue, this will trigger move assignment.
    
    上述的代码的分析过程与1中类似。
  3. 将右值绑定到右值引用上,不会触发移动语义,但可以延长对象的生命周期:
    std::string value = "HelloWorld";
    std::string &&rRef = std::move(value); // no movement occurs, value is still "HelloWorld".
    
    在上面的过程中,右值引用的行为表现与左值引用一致,任意一方发生修改,另一方也会同时修改。
  4. 右值引用赋值给某个变量,或者在初始化时赋值给某个变量,此时不会发生移动语义,因为根据之前讲的右值引用是一个左值:
    std::string &&rRef = std::string("HelloWorld");
    std::string value = rRef; // no movement occurs, rRef is still "HelloWorld".
    
    上述代码如果想要触发移动语义,则需要将value = rRef改成value = std::move(rRef)此时等式右侧为一个右值(将亡值),那么此时会触发移动语义,同时rRef在移动后会变成空串。

此时,对于之前的问题我们已经成功解决,只需要将参数改为右值引用,同时实现自己的移动构造函数与移动赋值运算符即可:

void c(const BigData &&c) { // do something }
void b(const BigData &&b) { C(std::move(b)); }
void a(const BigData &&a) { B(std::move(a)); }
a(BigData("data.txt"));

注意:在函数中调用时,需要使用std::move将右值引用参数(此时是一个左值)转换成右值进行传递。
注意:通常在确定使用std::move的时候,我们应该认为后续的函数调用可能发生移动语义,在后续我们应该提醒自己,被std::move后的变量可能已经变成空值了。
例如cppreference中的一个例子:

std::string str = "Salut";
std::vector<std::string> v;// uses the push_back(const T&) overload, which means 
// we'll incur the cost of copying str
v.push_back(str);
std::cout << "After copy, str is " << std::quoted(str) << '\n';// uses the rvalue reference push_back(T&&) overload, 
// which means no strings will be copied; instead, the contents
// of str will be moved into the vector. This is less
// expensive, but also means str might now be empty.
v.push_back(std::move(str));
std::cout << "After move, str is " << std::quoted(str) << '\n';std::cout << "The contents of the vector are { " << std::quoted(v[0])<< ", " << std::quoted(v[1]) << " }\n";

上述的程序输出如下:

After copy, str is "Salut"
After move, str is ""
The contents of the vector are { "Salut", "Salut" }

可以看到通过调用参数为右值引用的push_back后,str已经变成空串了(在调用过程中触发了移动语义)。

如何快速确定一个值是左值还是右值?

当不能很轻易的判断一个值是右值还是左值时,我们可以通过尝试将其绑定到一个左值引用或者右值引用上,查看是否能够通过编译,如果能够成功的绑定到左值引用上,那么就是左值,而如果能够绑定到右值引用上,那么就是一个右值.。

引用折叠与万能引用

引用折叠:指多余两个的&进行缩减至小于等于两个&的情况,cppreference中给出了如下的例子:

typedef int&  lref;
typedef int&& rref;
int n;lref&  r1 = n; // type of r1 is int&
lref&& r2 = n; // type of r2 is int&
rref&  r3 = n; // type of r3 is int&
rref&& r4 = 1; // type of r4 is int&&

也就是说只有当两个右值引用碰到一起时,其折叠结果才会是右值引用。
万能引用也叫做转发引用,其提出是为了解决完美转发的问题,之所以被叫做万能引用是因为其既能绑定左值也能够绑定右值。
通常情况下,万能引用通过如下方式定义:

template<class T>
void f(T &&t) {}; 
// NOTE!!!
// This is not a forwarding reference, because forwarding reference must be cv-unqualified
// template<class T>
// void f(const T &&t);

上面的代码看似是右值引用,其实是万能引用,下面给出几个例子说明:

int x = 0;
f(0); // call f(int &&t), T is int;
f(x); // call f(int &t), T is int&;
f(std::move(x)); // call f(int &&t), T is int&&;
int &lRef = x;
int &&rRef = 0;
f(lRef); // call f(int &t), T is int&;
f(rRef); // call f(int &t), T is int&;
f(std::move(lRef)); // call f(int &&t), T is int&&;
f(std::move(rRef)); // call f(int &&t), T is int&&;

上述的过程除了传入纯右值时,其余调用均发生了引用折叠。
简单总结来说:万能引用在传入的值为左值时,其被推导为左值引用,当传入的值为右值时,其被推导为右值引用。上述的过程中实际上发生了引用折叠,当传入类型为左值引用时,其被折叠成左值引用,当传入值为右值时,其被折叠成右值引用,唯一比较特殊的一点是:当传入非引用类型的左值时,此时也被折叠成了左值。
当然也可以指定参数类型(但是一般不这样操作,这里为了进一步演示引用折叠):

int x = 0;
f<int>(0) // no reference collapsing, call f(int &&t);
f<int&>(x) // & && -> &, call f(int &t);
f<int&&>(std::move(x)) // && && -> &&, call f(int &&t);

完美转发实现 std::forward

什么是完美转发?

首先我们需要知道什么叫转发,这里的转发指的时,函数调用过程中参数的传递过程。
完美转发则是指参数转发过程中保持其参数类型(指左右值),最外层函数如果传入的是一个右值,那么其希望在转发过程中该参数始终绑定到一个右值引用上(即继续作为右值),如果传入的是一个左值,那么其希望始终绑定到一个左值引用上(即继续作为左值)。

为什么需要完美转发?

当最外层函数传入一个右值时,外层的调用者是希望发生移动语义的(即在需要构造一个对象时,直接使用传入的右值),而不是希望其被复制到一个左值上发生不必要的数据拷贝。

完美转发实现

void handleInt(int &x){ }void handleInt(int &&xx) { }template<class T>
void perfectForwarding(T &&t)
{// do something ...// call another function:if (std::is_same_v<int, std::remove_reference_t<T>>) {handleInt(std::forward<T>(t));} else {// do something ...}
}
int x = 0;
int &lRef = x;
int &&rRef = 0;
perfectForwarding(1); // rvalue, so call handleInt(int &&x);
perfectForwarding(x); // lvalue, so call handleInt(int &x);
perfectForwarding(std::move(x)); //  rvalue, so call handleInt(int &&x);
perfectForwarding(lRef); // lvalue, so call handleInt(int &x);
perfectForwarding(rRef); // lvalue, so call handleInt(int &x);
perfectForwarding(std::move(lRef)); // rvalue, so call handleInt(int &&x);
perfectForwarding(std::move(rRef)); // rvalue, so call handleInt(int &&x);

完美转发实现原理

完美转发的实现基于万能引用和引用折叠,我们可以查看std::forward的源码:

template< class T >
T&& forward( typename std::remove_reference<T>::type& t ) noexcept;
template< class T >
T&& forward( typename std::remove_reference<T>::type&& t ) noexcept;

上面的代码有两个版本,这两个版本分别用于参数为左值和参数为右值(如果需要传入某个函数的返回值,且其返回值不是左值引用的时候)的时候。根据引用折叠的规则我们可以知道,当模板参数是左值引用时,其返回一个左值引用,当模板参数为右值引用的时候,其返回一个右值引用(由前面的介绍可以知道,函数返回值是右值引用时,其会被认为是一个右值,故此时可以绑定到参数为右值引用的函数上)。
std::remove_reference有什么作用,为什么需要这样做?

根据名字可以知道起作用是移除类型的引用,例如传入std::string&&那么type最后会变成std::string,上面实现了两个版本,其中一个版本参数为左值引用,另一个为右值引用,其目的就是为了能够让std::forward能够接收右值作为参数。

std::remove_reference如何移除引用?

通过类模板即可,下面是源代码:

template<typename _Tp>
struct remove_reference
{ typedef _Tp   type; };template<typename _Tp>
struct remove_reference<_Tp&>
{ typedef _Tp   type; };template<typename _Tp>
struct remove_reference<_Tp&&>
{ typedef _Tp   type; };

参考

value category cppreference
std::move cppreference
reference cppreference
std::forward cppreference

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/193915.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

链表【1】

文章目录 &#x1f348;2. 两数相加&#x1f34c;1. 题目&#x1f34f;2. 算法原理&#x1f353;3. 代码实现 &#x1f349;445. 两数相加 II&#x1f34d;1. 题目&#x1f350;2. 算法原理&#x1fad0;3. 代码实现 &#x1f348;2. 两数相加 &#x1f34c;1. 题目 题目链接&…

springboot整合easy-es实现数据的增删改查

背景 目前公司的一个老项目&#xff0c;查询贼慢&#xff0c;需要想办法提升一下速度&#xff0c;于是就想到了ES&#xff0c;现在尝试一下将ES整合到项目中来提升检索效率。 ES是基于倒排索引实现的&#xff0c;倒排索引中一个表相当于一个索引&#xff0c;表中的每条记录都…

编程实战:类C语法的编译型脚本解释器(五)变量表

系列入口&#xff1a; 编程实战&#xff1a;类C语法的编译型脚本解释器&#xff08;系列&#xff09;-CSDN博客 本文介绍变量表的实现。 目录 一、变量表的组织结构 二、代码 2.1 变量块 2.2 变量表&#xff08;栈&#xff09; 2.3 变量层级 2.4 变量查找 一、变量表的…

【【Micro Blaze 的 最后补充 与 回顾 】】

Micro Blaze 的 最后补充 与 回顾 Micro Blaze 最小系统 以 MicroBlaze 为核心、LocalMemory&#xff08;片上存储&#xff09;为内存&#xff0c;加上传输信息使用的 UART串口就构成了嵌入式最小系统。当程序比较简单时&#xff0c;Local Memory 可以作为程序的运行空间以及…

VUE语法--img图片不显示/img的src动态赋值图片显示

1、问题概述 常见情景1&#xff1a;在VUE中使用img显示图片的时候&#xff0c;通过传参的方式传入图片的路径和名称&#xff0c;VUE不加载本地资源而是通过http://localhost:8080/...的地址去加载网络资源&#xff0c;从而出现了图片无法显示的情况。 常见情景2&#xff1a;针…

python装饰器解析(关键点:高阶函数、嵌套函数)(参数化装饰器、类装饰器)

文章目录 Python装饰器解析什么是Python装饰器基础理解 如何创建装饰器&#xff08;关键点&#xff1a;高阶函数、嵌套函数&#xff09;创建基础装饰器 使用装饰器使用示例 装饰器的返回值参数化装饰器创建参数化装饰器语法示例使用示例 类装饰器创建类装饰器语法示例使用示例 …

Javaweb之Vue组件库Element案例异步数据加载的详细解析

4.4.3.6 异步数据加载 4.4.3.6.1 异步加载数据 对于案例&#xff0c;我们只差最后的数据了&#xff0c;而数据的mock地址已经提供&#xff1a;http://yapi.smart-xwork.cn/mock/169327/emp/list 我们最后要做的就是异步加载数据&#xff0c;所以我们需要使用axios发送ajax请…

C++内联函数

目录 一&#xff0c;概念 二&#xff0c;特性 三&#xff0c;与内联有关的面试题 四&#xff0c;auto关键字&#xff08;C11&#xff09; auto的使用细则 auto 不能推导的场景 基于范围的for循环&#xff08;C11&#xff09; 范围for的使用条件 五&#xff0c;指针空值…

解决vscode中html部分无法嵌套注释

不管是React项目还是Vue项目&#xff0c;相信你一定遇到过同样的问题&#xff0c;如果想要注释的结构内部也存在注释&#xff0c;那么编译器会报以下问题 使用 HTML-Comment 这个插件即可解决问题 选中需要注释的区域并根据系统输入快捷键&#xff0c;可以发现就算嵌套了注释…

matplotilb画图

Matplotlib 是支持 Python 语言的开源绘图库&#xff0c;因为其支持丰富的绘图类型、简单的绘图方式以及完善的接口文档&#xff0c;深受 Python 工程师、科研学者、数据工程师等各类人士的喜欢。Matplotlib 拥有着十分活跃的社区以及稳定的版本迭代&#xff0c;当我们使用 Pyt…

【Windows】如何实现 Windows 上面的C盘默认文件夹的完美迁移

如何实现 Windows 上面的C盘默认文件夹的完美迁移 1. 遇到的问题 在我想迁移C盘的 下载 和 视频 文件夹的时候&#xff0c;遇到了这样的问题&#xff0c;在迁移之后&#xff0c;我显卡录像的视频还是保存到了C盘默认位置里&#xff0c;以及我迁移了 下载 之后下载的盘依然是在…

轻盈悦耳的运动型气传导耳机,还有条夜跑灯,哈氪聆光体验

我平时出门不管是散步、骑行&#xff0c;还是坐公交的时候&#xff0c;都喜欢戴上耳机听音乐&#xff0c;这可以让我放松心情。现在市面上的耳机还是以真无线为主&#xff0c;选择虽多&#xff0c;但不适合户外使用&#xff0c;听不见外界的声音&#xff0c;运动时还容易脱落&a…

【附代码】Python函数性能测试(perfplot)

文章目录 相关文献测试电脑配置展开元素是list的list在numpy数组上映射函数的最有效方法数组numpy中唯一值的最有效频率计数方法反转numpy数组的最有效方法如何向 numpy 数组添加额外的列将 numpy 矩阵初始化为零或一以外的值 作者&#xff1a;小猪快跑 基础数学&计算数学&…

SpringMVC常用注解和用法总结

目标&#xff1a; 1. 熟悉使用SpringMVC中的常用注解 目录 前言 1. Controller 2. RestController 3. RequestMapping 4. RequestParam 5. PathVariable 6. SessionAttributes 7. CookieValue 前言 SpringMVC是一款用于构建基于Java的Web应用程序的框架&#xff0c;它通…

Debian12配置ssh服务器

Debian12配置ssh服务器 安装ssh-server sudo apt install openssh-server启动ssh sudo systemctl start ssh启用ssh sudo systemctl enable ssh查看ssh状态 sudo systemctl status ssh可以看到有enabled和running字样 说明ssh启用成功 连接到服务器 # username是你的用…

Lag-Llama:基于 LlaMa 的单变量时序预测基础模型

文章构建了一个通用单变量概率时间预测模型 Lag-Llama&#xff0c;在来自Monash Time Series库中的大量时序数据上进行了训练&#xff0c;并表现出良好的零样本预测能力。在介绍Lag-Llama之前&#xff0c;这里简单说明什么是概率时间预测模型。概率预测问题是指基于历史窗口内的…

基于Java SSM框架实现师生交流答疑作业系统项目【项目源码+论文说明】计算机毕业设计

基于java的SSM框架实现师生交流答疑作业系统演示 摘要 在新发展的时代&#xff0c;众多的软件被开发出来&#xff0c;给用户带来了很大的选择余地&#xff0c;而且人们越来越追求更个性的需求。在这种时代背景下&#xff0c;人们对师生交流平台越来越重视&#xff0c;更好的实…

CSP-坐标变换(其二)

问题描述 对于平面直角坐标系上的坐标 (x,y)&#xff0c;小 P 定义了如下两种操作&#xff1a; 拉伸 k 倍&#xff1a;横坐标 x 变为 kx&#xff0c;纵坐标 y 变为 ky&#xff1b; 旋转 θ&#xff1a;将坐标 (x,y) 绕坐标原点 (0,0) 逆时针旋转 θ 弧度&#xff08;0≤θ<…

【开源视频联动物联网平台】SIP协议的特点

SIP&#xff08;Session Initiation Protocol&#xff09;协议是一种用于建立、修改和终止多媒体通信会话的通信协议。在互联网电话、视频会议、即时消息传递和多媒体通信等领域&#xff0c;SIP协议得到了广泛的应用。可以说&#xff0c;SIP协议已成为目前通信系统协议的主流&a…

java高校实验室排课学生考勤系统springboot+vue

随着各高校办学规模的迅速扩大,学科专业的不断拓宽,传统的实验教学和实验室管理方法已经不能适应学校管理的要求,特别是化学实验室的管理,化学实验室仪器药品繁杂多样,管理任务繁重,目前主要使用人工记录方法管理,使用不便,效率低下,而且容易疏漏.时间一长将产生大量的文件和数…