前情提要
学过软件工程课就应该对协变和逆变不陌生了。
协变(Covariant)指的是允许将子类型隐式转换为父类型,通常发生在函数的返回值中。
1 | class Derived : Base { } |
方法返回Derived,但基于继承的“is-a”特点,当然可以把返回值当成Base使用。
逆变(Contravariant)指的是允许将父类型隐式转换为子类型,通常发生在函数的参数中。
1 | class Derived : Base { } |
方法需要一个Base,而Derived就是一个(is-a)Base,所以当然可以把Derived作为参数传入。
C# 里提供了接口泛型参数的协变和逆变,分别用out和in做标记。
1 | class Covariant<out T>{ T Data { get; } } |
在泛型参数中标记的好处是在做类型判断的时候就可以不用非得知道具体的泛型参数是啥了,增加了一些灵活性。
协变与逆变的逆转
进入正文,其实也很短。
1 | public interface IComponent<out T> where T : IModel |
挺有意思的,把这个拿去问LLM,问哪些行会报错哪些不会,问了几家的模型居然没有一个答对的。
注意到泛型被标记为out,也就是协变,我的第一直觉是它不能当参数,所以觉得前两个方法报错后两个方法不报错;第二直觉把鼠标滑到Action上一看,Action<in T>,那肯定不能往它放out的类型,所以第一和第三个方法报错。
然而事实是像上面的注释那样。其实也好理解,究其本质,协变指的是子类转父类,传入Action<T>可以理解为希望用这个action对Model做点什么,那如果给action传入的是Model的父类,也就是在把Model看成父类的情况下对它做点什么,那当然是可以的;而传入Func<T>可以理解为希望用这个工厂方法设置Model的值,那如果通过这个工厂方法得到的是Model的父类,当然不能把Model的父类赋给Model。
作为返回值时,返回Action<T>可以理解为我希望用这个action对外界的T做点什么,但如果外界提供的是其父类,那我想做的事情可能就做不到了;而返回Func<T>可以理解为我希望用返回的工厂为外界提供T,那我返回的这些T当然也可以看成它的父类。
从结果上来看,当参数传的是接受逆变参数的另一个泛型类时,原本代表“in”的参数,加上接受“in”的泛型类,反而成为了out的生态位,这也就是标题里提到的协变与逆变的逆转。总结规律就是,一个逆变泛型类会改变原来位置(返回值、参数)的生态位,相当于将其“乘以-1”,而协变泛型类则不改变生态位,相当于“乘以1”。
同样的道理,以下的方法定义的合法性在注释中给出;但是想要以一个“自然”的解释去理解它们就有点抽象了,还是直接记住规律吧(如果真的会用得到的话)。
1 | public interface ICovariant<out T> // 改成<in T>就是结果反过来 |
闲话
其实有好多想写博客的东西的,但是写博客有点花时间(这篇就写了我快一个小时),并且有些想分享的话题虽然在我自己的项目里已经够用了,但是作为博客又有点不全面,于是就想着了解得全面一点再写吧,可是哪有时间给我把它了解得这么全面呢。
这篇的内容是我在构建一个自己的ECS框架时无意遇见的,感觉挺有意思,且之前好像都没留意过这个问题,就记录一下吧。