深入理解C#的协变和逆变及其限制原因

news/2024/4/20 9:12:09/文章来源:https://blog.csdn.net/fjjaylz/article/details/129241972

阅读本文需要的一些前置知识:
C#基本语法、C#的泛型使用、C#的运行过程
由于协变和逆变存在一些细节,在阅读时请注意“接口”和“类型”的差异,此外,文中有可能在不同的语境中将“结构体”和“值类型”混用,但表达的同一个意思。

协变和逆变是一个C#的泛型开发中经常会遇到但可能意识不到的问题,往往会遇到一些认为应该可以但结果却发生了编译器报错表示类型不正确的情况,虽然有一些其他的方式绕过一些限制,但理解了协变和逆变可以在遇到错误时清晰地认识到问题的所在,也有助于写出更健壮的代码。

从一个例子开始

从一个在平时开发中可能遇到的问题下始,看下面几行代码。

string str = "";
object obj = str;//通过编译
string[] strArray = new string[1];
object[] objArray = strArray;//通过编译
List<string> strList = new List<string>();
List<object> objList = strList;//编译器报错 Cannot convert source type 'System.Collections.Generic.List<string>' to target type 'System.Collections.Generic.List<object>'

我们知道,C#中所有类型都是从object派生的,因此下面object obj = str;是符合逻辑的,同样object[] objArray = strArray也是符合预期的,但是为什么使用泛型List<object> objList = strList;时却无法通过编译呢?
这就是本篇文章的主题:变体(Variance),上面的object[] objArray = strArray形式被称为协变,此外还有另一种变体形式被称为逆变(一些书籍或文章翻译为抗变)。
变体的主要形式包括协变(Covariance)逆变(Contravariance),后面我会使用What-Why-How的方式逐个说明这两个概念。

协变(Covariance)

What-什么是协变

  1. 范畴学定义
    假定有两个类型X和Y,并且X的每个实例都能转换为Y类型(类似于C#中的继承/实现,即X是Y的派生类型)。如果对于一个与X和Y相关的类型I<X>和I<Y>,每一个I<X>的实例都能转换为I<Y>类型,那么就说I<>是协变的。
  2. C#举例说明
    定义比较干巴巴,举个实例来看看可以更清晰,还是上面那一段代码:
    string[] strArray = new string[1];
    object[] objArray = strArray;//通过编译
    
    stringobject的派生类,每一个string[]都可以转换为object[],这个string[]转换为object[]的过程就称为协变,也可以说“数组是协变的”。

Why-为什么要使用协变

1. 协变的使用场景

为什么要提出来协变的概念呢?事实上这不仅是一个非常正常的思考逻辑,而且还具有一定的实用意义。
假设这样一个场景:
我们有一个Log函数,需要使用它来格式化一系列对象的输出,要求它能支持所有类型的对象。假设我们使用的是泛型List,那么很自然地,我们的函数可能会写成下面这样:

string Log(List<object> outputList)
{string output = "";foreach (var obj in outputList){output += obj.ToString();}return output;
}

当我们有一个List想要输出时,就会使用下面这样的代码:

 List<string> strList = new List<string>();string output = Log(strList);//Argument type 'System.Collections.Generic.List<string>' is not assignable to parameter type 'System.Collections.Generic.List<object>'

遗憾的是,如果真的这么写,就无法通过编译了,因为List<>这个泛型类并不支持协变。如果我们真的要使用List<>来格式化,就需要为每个不同的类写不同的重载,很明显这是不可行的。(其实可以使用string Log<T>(List<T> outputList)的签名来实现,但这是泛型方法的话题了。)
但同样明显的是,在这个案例中这样的使用方式是类型安全的,那么为什么C#要禁止协变呢?

2. 协变会带来什么问题

从例子中最容易看明白,直接上代码。

string[] strArray = new string[1];
object[] objArray = strArray;//通过编译
objArray[0] = new object();//运行时报错:System.ArrayTypeMismatchException

由于数组是支持协变的,我们构造一个string[]并且将其赋值组object[],然后向数组中插入一个object。此时会发现可以通过编译,但在运行时会抛出System.ArrayTypeMismatchException错误,插入的object类型不匹配。
在构造数组时,我们所获得的是一个string[],如果没有将其转换为object[],插入一个object很明显是不正确的,但经过协变转换后,它却通过了编译。
也就是说,在这种情况下的协变会导致一个运行时错误

3. 什么情况下可以使用协变

对比上面两个例子,我们知道协变有其特定的用处,但在一些情况下并不能保证程序的正确,所以协变只能在一些受限的情况下使用。在第一个例子里,我们需要的是对List<>进行读取并且确认是类型安全的,而第二个则是需要对实便行修改造成了程序错误。因此C#将泛型的协变限制为仅在读取时可用。

How-如何在C#中使用协变-out关键字

1. out标记

那么要怎么实现现读取和修改分开呢,其实只要能保证被转换后的类型只能被读取而不能被修改,那么就可以安全地使用协变了。
于是C#设计者采用了out关键字用于标记协变的方案,当使用这个关键字时,表明这个被修饰的类型仅被用于输出,并且不能被传入。在编译时,编译器会主动检查被标记类型参数的所有调用,确保它不会被作为传入类型使用。
在C#中就有这样的一个接口:IReadOnlyList<>,它的声明如下:

  public interface IReadOnlyList<out T> : IReadOnlyCollection<T>, IEnumerable<T>, IEnumerable{T this[int index] { get; }}

可以看到在它的类型参数前使用了out进行标记。如果再深入查看,会发现它继承的两个接口IReadOnlyCollection<T>IEnumerable<T>的类型参数也都是进了行out标记的。
我们经常使用的List<>就实现了这个接口:

  public class List<T> : IList<T>,ICollection<T>,IEnumerable<T>,IEnumerable,IList,ICollection,IReadOnlyList<T>,//实现接口IReadOnlyCollection<T>{public T this[int index]{get{//具体实现}}//...其他成员实现    }

因此我们一开始的代码可以这样写:

    string Log(IReadOnlyList<object> outputList)//注意接口类型{string output = "";foreach (var obj in outputList){output += obj.ToString();}return output;}//调用代码List<string> strList = new List<string>();string output = Log(strList);

在这里,strList协变成为了IReadOnlyList<object>

2. 协变的一些限制

虽然看起来只需要用out进行标记就可以实现协变,但在实际使用中还存在一些其他需要注意的地方,具体的原因我会放到文章最后详细说明。

  1. 在C#中,只有泛型接口泛型委托是协变的,泛型类和泛型结构体不是
    泛型是否协变举例
    泛型接口IReadOnlyList<object> lrol = new List<string>()
    泛型委托 Func<string> funcStr = () => ""; Func<object> funcObj= funcStr;
    泛型类不是|
    泛型结构体不是|
  2. 泛型的类型参数中类(class)是协变的,结构体(struct)不是。
    泛型是否协变举例
    泛型类参数IReadOnlyList<object> lrol = new List<string>()
    泛型结构体参数不是注意不可用IReadOnlyList<object> lrol = new List<int>()
  3. 必须显式声明协变,即标记out。注意由于第一条的存在,不能对class或者struct使用out关键字。
  class Pair<out T>{}//会提示编译错误,Variant type parameters could be declared in interfaces or delegates only(变体类型参数仅能被用于接口或委托声明)

逆变(Contravariance)

What-什么是逆变

  1. 范畴学定义
    假定有两个类型X和Y,并且X的每个实例都能转换为Y类型(类似于C#中的继承/实现,即X是Y的派生类型)。如果对于一个与X和Y相关的类型I<X>和I<Y>,每一个I<Y>的实例都能转换为I<X>类型,那么就说I<>是逆变的。(注意加粗的文本,与协变正好相反。)
  2. C#举例说明
    逆变相比协变要更难理解一些,还是举个实例来看看。
    Action<object> objAction = o => { o.GetType(); };
    Action<string> strAction = objAction;//可以通过编译
    
    stringobject的派生类,在这里objActionAction<object>的实例,在调用时需要传入一个object类型的实参。当我们向objAction中传入一个string对象时,由于stringobject的派生类,那么理所应当是可以被调用的。
    但是如果放到Action<>上来看,则是反过来了——与类型的派生方向相反,Action<object>对象转换成了一个Action<string>
    正因为与派生关系相反,因此叫它逆变

Why-为什么要使用逆变

1. 逆变的使用场景

上面的例子已经足以说明逆变的作用了,如果不允许逆变的存在,那么在Action<string> strAction = objAction;这样的代码就不能存在,这明显是不合理的,因为string派生自objectobject可以使用的成员在string中必然也存在。
前面使用了委托作为案例,下面再举一个接口的例子。
假设我们有一个Graph类及从它派生的CirleTriangle类,然后建立一个对Graph进行绘制的接口IDrawer<T>和对IDrawer<T>的实现Drawer。

    class Graph { }class Circle : Graph { }class Triangle : Graph { }interface IDrawer<T> where T : Graph{void DrawGraph(T graph);}class Drawer : IDrawer<Graph>{public void DrawGraph(Graph graph){//具体实现}}

由于CircleTriangle都派生自Graph,因此IDrawer<Graph>DrawGraph都可以正常将TriangleCircle的实例作为参数,所以凡是需要一个IDrawer<Circle>的地方,都可以使用IDrawer<Graph>来代替,而不需要为每一个派生自Graph的都单独声明一个实现类。如果没有逆变,下面的调用将出现错误,但根据我们的分析,这是安全的。

    IDrawer<Graph> graphDrawer = new Drawer();graphDrawer.DrawGraph(new Triangle());//安全调用graphDrawer.DrawGraph(new Circle());//安全调用IDrawer<Circle> circleDrawer = graphDrawer;//需要逆变,编译时会发生错误,但实际上是类型安全的circleDrawer.DrawGraph(new Circle());//安全调用IDrawer<Triangle> triangleDrawer = graphDrawer;//需要逆变,编译时会发生错误,但实际上是类型安全的triangleDrawer.DrawGraph(new Triangle());//安全调用

2. 逆变可能带来哪些问题

与协变一样,如果不加限制地允许逆变,同样会带来一些问题,看下面这个例子:

    List<object> objList = new List<object>() { new object() };List<string> strList = objList;//逆变,编译时会发生错误string str = strList[0];//类型不匹配,必然出错

很容易看出,objectList中唯一的对象是一个object,无法作为string,运行时一定会出错。所以在这种情况下,即使在编译时允许了逆变,运行起来也会出错。

3. 什么情况下可以允许逆变

现在我们遇到了和协变一样的问题,在一些情况下是应该允许的(作为调用的实参),但在另一些情况下又不能允许(作为返回的结果),所以限制的方式也呼之欲出:当只作为传入参数时允许逆变

How-如何使用逆变

1. 使用in标记逆变

作为协变的反方向变化,C#同样提供了类似的方式来对泛型类型参数进行标记和限制:in关键字。
以上面的绘制代码为例,由于我们需要允许IDrawer的逆变,那么就需要在IDrawer接口上增加关键字in,如下面这样:

    class Graph { }class Circle : Graph { }class Triangle : Graph { }interface IDrawer<in T> where T : Graph//注意这一行在T前增加了in关键字{void DrawGraph(T graph);}class Drawer : IDrawer<Graph>{public void DrawGraph(Graph graph){//具体实现}}

只需要进行这一个修改就可以允许逆变了。

2. 逆变的限制

逆变的限制与协变基本相同。

  1. 在C#中,只有泛型接口泛型委托是逆变的,泛型类和泛型结构体不是
  2. 泛型的类型参数中类(class,即引用类型)是逆变的,结构体(struct,即值类型)不是。
  3. 必须显式声明逆变,即标记in。

协变和逆变在C#中受限制的原因

在前面两部分中都提到协变和逆变受到了限制,但为什么会有这样的限制?主要原因是公共语言运行时(Common Language Runtime, CLR)的泛型机制造成的。

1. 协变和逆变本质上是隐式类型转换

协变和逆变本质上是隐式类型转换,这个原因导致了泛型类和结构体无法协变和逆变。
理解这一点非常重要,我们知道在非泛型的情况下,两个不同类型想要隐式转换,只有两种情况,一是存在隐式转换的函数,例如intdouble;二是存在继承或实现关系,例如stringobjectIReadOnlyList<>List<>

//存在隐式转换
int a = 1;
double b = a;//存在继承关系
string str = "";
object o = str;

按这个关系,我们再来考虑泛型类List,使用下面两行代码进行输出,会发现List<object>List<string>并不属于同一个类。

Console.WriteLine($"typeof(List<object>)={typeof(List<object>)}");
Console.WriteLine($"typeof(List<string>)={typeof(List<string>)}");//输出:
//typeof(List<object>)=System.Collections.Generic.List`1[System.Object]
//typeof(List<string>)=System.Collections.Generic.List`1[System.String]

很明显,List<object>List<string>既不存在继承关系,也不可能存在隐式转换函数,类型本就不一样,自然无法进行转换。
当然,这并不能说明为什么接口和委托可以进行转换,请继续往下看。

2. 泛型的CLR运行机制是类型参数为结构体的泛型接口/委托不能逆变和协变的根本原因

既然协变和逆变的本质是隐式类型转换,那如果一个值类型实现了接口,不就有了继承/实现关系吗?那为什么又会有值类型的泛型接口/委托不能逆变和协变的限制呢?
同样地,对于接口和委托而言,协变和逆变也不存在继承或实现关系,为什么却可以进行类型转换呢?难道它们之间存在什么特殊的隐式转换吗?

我们来看下面这个简单的例子,结构体People实现了接口IWalk,然后对构造一个泛型委托Action<IWalk>并向Action<People>逆变。如果进行编译,编译器会对逆变这一行代码提示错误。

    interface IWalk { }struct People : IWalk { }/Action<IWalk> aWalk = (walk) => { };Action<People> aPeople = aw;//实现了接口的结构体逆变,无法通过编译

造成这个限制的原因是CLR的运行机制。我们在C#中写的泛型会通过以下几个步骤来运行:

  1. C#静态编译为IL代码

  2. CLR创建开放的泛型类,并在调用处将类型参数传入

  3. CLR根据传入参数成为完整的类型。
    对于值类型,每个不同的类型参数创建不同的类,也就是说Action<int>Action<long>在CLR中会构造两个不同的类;
    对于引用类型,每个类型参数都使用object作为类型,也就是说Action<string>Action<object>在CLR中只会构造一个Action<object>类,并且都使用它,这是因为引用类型本质上只是一个指针。
    三个步骤可以参考下面的分支图

    静态编译
    值类型
    引用类型
    C#泛型
    IL泛型模板+调用时类型参数
    参数类型
    不同的值类型创建不同的泛型类
    所有引用类型都使用object作为类型参数

基于这个运行方式,我们可以发现,对于同一个泛型接口或委托而言,任何一个值类型生成的泛型都不可能和引用类型生成的泛型类型相同;而对于一个引用类型而言,它们在CLR中使用的始终是同一个类,只要编译器静态验证是合法的类型,那么就可以进行类型的转换。
在上面的代码中,对于aWalk而言,它在CLR中使用的是Action<object>这个类,而aPeople则是Action<object>,同样不存在继承关系,而C#也没有提供泛型的自定义隐式转换方式,所以自然也无法进行转型了。

现在让我们再来思考为什么接口和委托可以进行逆变或协变,我们知道在C#中为接口和委托提供了inout关键字进行标记,由于声明该接口或委托可以被用于协变或逆变。事实上在CLR运行时并不会验证这些类是不是匹配的(严格来说还是会验证,当类型出错还是会报错),因为对于CLR而言,它们都object类的泛型,这个报错只是由C#编译器进行判断接口或委托的内容是不是匹配。

3.必须显式使用in或out进行标记的原因

C#既然可以进行语法分析,自然也可以识别一个接口或委托是否支持协变或逆变,为什么不在识别到某个类需要协变或逆变的时候自动加上呢?这是因为自动识别有可能改变原有的设计意图
还是以之前的Graph-Drawer为例,假设我们新增了一个DrawerInvoker类,它传入一个IDrawer<Graph>对象,用于之后调用Drawer。

class Graph { }
class Circle : Graph { }
class Triangle : Graph { }interface IDrawer<T> where T : Graph//没有标记协变或逆变
{void DrawGraph(T graph);
}class Drawer : IDrawer<Graph>
{public void DrawGraph(Graph graph){//具体实现}
}class DrawerInvoker 
{ public DrawerInvoker(IDrawer<Graph> drawer) {//具体实现}
}//-------------调用1----------
Drawer<Graph> graphDrawer = new Drawer<Graph>();IDrawer<Circle> circleDrawer = graphDrawer;//需要逆变
circleDrawer.DrawGraph(new Circle());DrawerInvoker drawerInvoker = new DrawerInvoker(circleDrawer);//需要协变

在上面这个例子中,假设我们同时写了需要逆变和需要协变的代码,编译器就无法确认IDrawer到底需要标记in还是out了——而且很明显,同一个接口的同一个类型参数并不能既协变又逆变。
更可怕的是,如果接口和调用在两个不同的程序集中,这还会影响接口定义处的逆变/协变性!也就是说,程序集外其他人的代码有可能改变程序集内的代码属性,这种情况明显存在巨大的安全风险。

到这里本篇文章的全部内容就都结束了,后面是一个简单的思维导图,希望可以对理解有一些帮助。

关于协变和逆变的思维导图

在这里插入图片描述

参考文献
C#本质论 第四版 C#5.0
.Net CLR Via C#
泛型接口中的变体 (C#)

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

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

相关文章

深入浅出1588v2(PTP)里的时间同步原理

1.时间同步1.1 单步同步(OneStep)单步同步最为简单&#xff0c;master向slave发送一个sync的同步包&#xff0c;同步包里带有这条信息发送时master的当前时间t1&#xff0c;假如这条信息从master传输到slave需要的传输时间是D&#xff0c;那么slave收到信息时&#xff0c;maste…

BIM小技巧丨关于如何在Revit明细表中显示门窗面积

在明细表中显示门窗面积(以门明细表为例)在新建一个门明细表后&#xff0c;可以发现在Revit中不能直接使用明细表统计门窗面积。 这时&#xff0c;可以通过使用添加“计算值”的方式来处理&#xff0c;得到如下图所示&#xff0c;两种不同的面积统计结果&#xff1a; 除此之外&…

前端基础之CSS扫盲

文章目录一. CSS基本规范1. 基本语法格式2. 在HTML引入CSS3. 选择器分类二. CSS常用属性1. 文本属性2. 文本格式3. 背景属性4. 圆角矩形和圆5. 元素的显示模式6. CSS盒子模型7. 弹性布局光使用HTML来写一个前端页面的话其实只是写了一个大体的框架, 整体的页面并不工整美观, 而…

ledcode【用队列实现栈】

目录 题目描述&#xff1a; 解析题目 代码解析 1.封装一个队列 1.2封装带两个队列的结构体 1.3封装指向队列的结构体 1.4入栈函数实现 1.5出栈函数实现 1.6取栈顶数据 1.7判空函数实现 题目描述&#xff1a; 解析题目 这个题我是用c语言写的&#xff0c;所以队列的pu…

JavaSE-3 Java运行原理

一、Java的运行过程 &#x1f34e;Java程序运行时,必须经过编译和运行两个步骤。 首先将后缀名为.java的源文件进行编译,最终生成后缀名为.class的字节码文件。然后Java虚拟机将字节码文件进行解释执行,并将结果显示出来。具体过程如下图所示。 &#x1f349;Java程序的运行过…

【Python数据挖掘入门】2.2文本分析-中文分词(jieba库cut方法/自定义词典load_userdict/语料库分词)

中文分词就是将一个汉字序列切分成一个一个单独的词。例如&#xff1a; 另外还有停用词的概念&#xff0c;停用词是指在数据处理时&#xff0c;需要过滤掉的某些字或词。 一、jieba库 安装过程见&#xff1a;https://blog.csdn.net/momomuabc/article/details/128198306 ji…

数字IC手撕代码--小米科技(除法器设计)

前言&#xff1a; 本专栏旨在记录高频笔面试手撕代码题&#xff0c;以备数字前端秋招&#xff0c;本专栏所有文章提供原理分析、代码及波形&#xff0c;所有代码均经过本人验证。目录如下&#xff1a;1.数字IC手撕代码-分频器&#xff08;任意偶数分频&#xff09;2.数字IC手撕…

原始GAN-pytorch-生成MNIST数据集(代码)

文章目录原始GAN生成MNIST数据集1. Data loading and preparing2. Dataset and Model parameter3. Result save path4. Model define6. Training7. predict原始GAN生成MNIST数据集 原理很简单&#xff0c;可以参考原理部分原始GAN-pytorch-生成MNIST数据集&#xff08;原理&am…

记一次线上es慢查询导致的服务不可用

现象 某日线上业务同学反馈订单列表查询页面一直loding&#xff0c;然后提示请求超时&#xff0c;几分钟之后恢复正常 接到报障之后&#xff0c;马上根据接口URL&#xff0c;定位到了请求链路&#xff0c;发现是es查询超时&#xff0c;这里我们的业务订单表数据是由几百万的&a…

如何基于MLServer构建Python机器学习服务

文章目录前言一、数据集二、训练 Scikit-learn 模型三、基于MLSever构建Scikit-learn服务四、测试模型五、训练 XGBoost 模型六、服务多个模型七、测试多个模型的准确性总结参考前言 在过去我们训练模型&#xff0c;往往通过编写flask代码或者容器化我们的模型并在docker中运行…

Python学习笔记202302

1、numpy.empty 作用&#xff1a;根据给定的维度和数值类型返回一个新的数组&#xff0c;其元素不进行初始化。 用法&#xff1a;numpy.empty(shape, dtypefloat, order‘C’) 2、logging.debug 作用&#xff1a;Python 的日志记录工具&#xff0c;这个模块为应用与库实现了灵…

C# Sqlite数据库加密

sqlite官方的数据库加密是收费的&#xff0c;而且比较贵。 幸亏微软提供了一种免费的方法。 1 sqlite加密demo 这里我做了一个小的demo演示如下&#xff1a; 在界面中拖入数据库名、密码、以及保存的路径 比如我选择保存路径桌面的sqlite目录&#xff0c;数据库名guigutool…

Verilog 学习第五节(串口接收部分)

小梅哥串口部分学习part2 串口通信接收原理串口通信接收程序设计与调试巧用位操作优化串口接收逻辑设计串口接收模块的项目应用案例串口通信接收原理 在采样的时候没有必要一直判断一个clk内全部都是高/低电平&#xff0c;如果采用直接对中间点进行判断的话&#xff0c;很有可能…

Linux 红帽9.0 本地源 与 网络源 搭建

本次我们使用的是 redhat 9.0 版本&#xff0c;是redhat 的最新版本&#xff0c;我们一起来对其进行 本地仓库 和 网络仓库的搭建部署~&#xff01;&#xff01;关于 本地仓库&#xff08; 本地源 &#xff09;&#xff0c;和 网络仓库 &#xff08; 网络源 &#xff09;&#…

ESP32蓝牙配网

注意********menuconfig 配置&#xff08;必须打开蓝牙我这是C2所以使用NimBLE &#xff09;可以直接从demo的配置文件拷贝 Component config ---> Bluetooth ---> NimBLE - BLE only Component config ---> Bluetooth ---> NimBLE Options ---> Enable blufi…

计算结构体大小

计算结构体大小 目录计算结构体大小一. 结构体内存对齐1. 简介2. 嵌套结构体二. offsetof三. 内存对齐的意义四. 修改默认对齐数一. 结构体内存对齐 以字节&#xff08;bety&#xff09;为单位 1. 简介 对于结构体成员在内存里的存储&#xff0c;存在结构体的对齐规则&#…

Vue下载安装步骤的详细教程(亲测有效) 1

目录 一、【准备工作】nodejs下载安装(npm环境) 1 下载安装nodejs 2 查看环境变量是否添加成功 3、验证是否安装成功 4、修改模块下载位置 &#xff08;1&#xff09;查看npm默认存放位置 &#xff08;2&#xff09;在 nodejs 安装目录下&#xff0c;创建 “node_global…

Java查漏补缺(14)数据结构剖析、一维数组、链表、栈、队列、树与二叉树、List接口分析、Map接口分析、Set接口分析、HashMap的相关问题

Java查漏补缺&#xff08;14&#xff09;数据结构剖析、一维数组、链表、栈、队列、树与二叉树、List接口分析、Map接口分析、Set接口分析、HashMap的相关问题本章专题与脉络1. 数据结构剖析1.1 研究对象一&#xff1a;数据间逻辑关系1.2 研究对象二&#xff1a;数据的存储结构…

Laravel框架04:视图与CSRF攻击

Laravel框架04&#xff1a;视图与CSRF攻击一、视图概述二、变量分配与展示三、模板中直接使用函数四、循环与分支语法标签五、模板继承、包含1. 继承2. 包含六、外部静态文件引入七、CSRF攻击概述八、从CSRF验证中排除例外路由一、视图概述 视图存放在 resources/views 目录下…

MyBatis学习笔记(七) —— 特殊SQL的执行

7、特殊SQL的执行 7.1、模糊查询 模糊查询的三种方式&#xff1a; 方式1&#xff1a;select * from t_user where username like ‘%${mohu}%’ 方式2&#xff1a;select * from t_user where username like concat(‘%’,#{mohu},‘%’) 方式3&#xff1a;select * from t_u…