协变与逆变

注:结论直接看文章底部

什么是协变?协同变化,遵循一般规律。

什么是逆变?逆向变化,违逆一般规律。

那么,什么是一般规律呢?例如:中国人是人。

遵循一般规律无需多说,违逆一般规律就是【人是中国人】,先不讨论从语义上对于不对,逆变就是如此。

从自然规律来讲,其实是无需协变与逆变的,但计算机语言的实现过程中,缺少协变与逆变,其实会导致语义缺陷或编译缺陷。

例子依然是中国人是人,在编程语言中,很容易有以下两个类:

1 class People
2 {}
3 class ChinesePeople:People
4 {}

在使用时

1 People p=new People();//人是人,可以
2 People p=new ChinesePeople();//中国人是人,可以
3 List<People> pList=new List<ChinesePeople>();//一群中国人是一群人,不可以,why?

为什么不可以呢?就一般规律来讲,一群中国人确实是一群人啊,没毛病啊!!!

确实,从语义上来讲没问题,可问题在于对于编译器来说,不认识啊。

在IL中,People与ChinesePeople是有继承关系的,但是List<People>与List<ChinesePeople>在IL中,分别对应的是两个类,这两个类之间并没有继承关系。所以就造成了这种语义上合法,但语法上不合法的情况。如下图所示:

 

 协变就是为了解决这种情况而诞生的。通过out T,指定返回类型可以为指定类型的子类,从而允许IEnumerable<People> p=new List<ChinesePeople>()写法,如果没有out,这种写法就不能通过编译器

 所以,我说协变就是协助达成这种一般规律上来说应该存在继承关系的代码,使“一群中国人是一群人”这个现象在代码层面实现。

需要注意的是:协变指定的泛型会约束类型只允许存在于返回值中,不允许作为参数存在


public interface IPeople<out T>
{
   public T Get(){}//正确
  public void SayHello(T t,string msg);//错误 }

为何错误呢?假设接口已经实现,有以下场景:

IPeople<People> p=new People<People>();
var model=p.Get();//返回值为People类型
//假如协变允许T作为入参类型,就有:
p.SayHello(new People(),"Hello");//语法通过,编译通过
p.SayHello(new ChinesePeople(),"中国人");//语法通过,编译通过,但会出现数据丢失问题,而且很严重
//首先说明:p.SayHello对编译器来说应该是:p.SayHello(People p,string),这里输入的是p.SayHello(ChinesePeople p2,string),从语法语义上来说都没有错,p=p2这个行为和Peple p=new ChinesePeople()一般无二,看起来没有任何问题。

 那么,问题出在哪里呢?

众所周知,子类是在父类基础上的扩展,对于公共成员来说,子类拥有的成员父类不一定会有,所以,在进行子类到父类的转换时,数据丢失是必然的,毕竟,对于父类而言,子类的一些属性没有地方可以存放,就只能丢弃。

常见的案例就是,【double】3.14=>【int】3=>【double】3.0,这个例子虽然不完全合适,但也很准确的说明了精度丢失的现象,在 子类->父类->子类的过程中,物是人非,失去的再也找不回来了,子类已不是原来的子类了。

那么,为什么允许【父类 p=new 子类()】这种语法通过呢?以我的理解:

第一、语义问题,面向对象语言是高级语言,尽量靠近人类的思考习惯,以方便理解与开发;

第二、目标问题,左值是作为目标存在的,右值是作为入参存在的,只要对右值的加工能完成对左值的正确赋值,至于右值之后的死活,已经不太重要了。而ChinesePeople中自然包含People所需的成员,为People赋值绰绰有余。

但为什么作为参数就不行了呢?在我看来,情况有二:

第一、作为参数,本来就是想传People,子类ChinesePeople只是数据的载体,那自然没有问题;

第二、想传的就是ChinesePeople,在方法中会根据类型进行对应数据处理,这时候就会出现数据丢失的问题。

那怎么处理呢?这时候,就该逆变登场了。

逆变用<in T>,被in标注的in,只能用作为方法的参数,

public interface IPeopleIn<in T>
{

  public T Get();//错误
  public void SayHello(T t, string msg);//正确
}

分别实现IPeopleIn<in T>接口:

public class People: IPeopleIn<People>
    {
        public string Name { get; set; }
        public void SayHello(People t, string msg="Hello") {
            Console.WriteLine($"{t.Name}say:Hello");
        }
    }

    public class ChinesePeople : People, IPeopleIn<ChinesePeople>
    {
        public void SayHello(ChinesePeople t, string msg= "您好")
        {
            Console.WriteLine($"{t.Name}说:您好");
        }
    }

进行调用测试:

 1       /// <summary>
 2         /// 逆变
 3         /// </summary>
 4         public static void TestInversion()
 5         {
 6             IPeopleIn<People> p = new People();
 7             p.SayHello(new People() { Name="Arvin Jing"});
 8             IPeopleIn<ChinesePeople> p2= new ChinesePeople();
 9             p2.SayHello(new ChinesePeople() { Name = "景**" });
10             IPeopleIn<ChinesePeople> p3 = new People();//这种写法就是逆变名字的由来,这种写法相当于人是中国人,就是我说的违逆一般规律
11             p3.SayHello(new ChinesePeople() { Name = "abc" });
12         }

测试结果:

 

 从测试结果来看,逆变是对多态方案的优秀支持,通过右值决定调用的方法是什么。

对于逆变,有两个问题需要思考一下:

第一:逆变为什么允许声明基类

第二:逆变为什么不允许作为返回类型

个人理解:

第一个问题回答:源于继承的特性,只要父类不刻意隐藏,子类必然能访问到父类的成员,那么,作为逆变入参的右值,变相的多态,允许基类调用也是自然的。

第二个问题回答:若作为返回类型,就相当允许ChinesePeople p=new People(),但是,People中是没有完整的ChinesePeople的成员的,而ChinesePeople作为左值目标,右值People是完不成对目标的赋值的。父类与子类的关系,就像时间长河中现代人与古代人的关系,现代人知道古代人的事情,古代人不会知道现代的事情,当然,非要抬杠说预言或者推衍,我也无话可说。

文章到此,讨论的内容已基本结束,接下来就是归纳总结了。。。

总结:

源起:为了解决继承关系在经过泛型变化后失去继承特性的问题,可以说,协变是对继承的补充完善。

方案:

  行为化约束:逆变协变只支持接口和委托,就是以行为为导向

  参数责任分化:in为逆变,专注于成员入参,out为协变,专注于成员出参。方法是多参数,唯一返回或无返回,以此推衍,一目了然。举例:Action<in T1,in T2,in T3>和Func<in T1,in T2,in T3,out TResult>

  原则导向:以面向对象的基本特性和数据安全为基本原则。在实现过程中,逆变看似违背一般规律,其实却牢牢遵循基本原则,如果在写法上遵循的一般规律,才会实质上破坏基本原则,导致数据丢失、找不到父类、父类越权访问子类等问题。

结果:

  协变:完善了继承特性,解决了类似“一群中国人不是一群人”,“加工黄豆的工艺不是加工豆类的工艺的”,“白马非马”这类逻辑陷阱

  逆变:完善了多态,以类为导向,根据声明方法的不同,父类与子类使用不同的方法,而这种多态,无需像常规的多态进行细化,只需要指明右值,就能调用对于应的一系列类,无需担心调用层次混乱的问题。

注意:

  使用协变和逆变时,左值只能作为接口或委托存在

  逆变为入(in),协变为出(out);逆变专注于参数类型,协变专注于返回值类型

  协变支持了 变种父类=变种子类、变种父类=变种父类,左值决定了类型上限

  逆变支持了 变种子类=变种父类、变种子类=变种子类 ,前面调用父类方法,后面调用子类方法,和接口声明的实际类型无关,左值决定了类型下限

posted @ 2021-01-28 11:55  ArvinJing  阅读(350)  评论(0编辑  收藏  举报