问题

编程语言的类型推断怎么解决协变和逆变的问题?

回答
类型推断机制,在许多现代编程语言中扮演着至关重要的角色,它能够自动确定变量、函数参数和返回值的类型,从而简化代码编写,减少类型错误。然而,当涉及到泛型和继承关系时,一个棘手的挑战便出现了:如何处理“协变”和“逆变”这些与类型安全息息相关的概念。

首先,我们得明白协变和逆变到底是怎么一回事。想象一下,我们有一个表示“哺乳动物”的基类,以及一个派生类“狗”。

协变(Covariance):允许我们将派生类型的实例传递给期望基类型实例的地方。举个例子,如果一个函数接受一个“哺乳动物”的列表,那么传入一个“狗”的列表应该是可以的,因为“狗”也是“哺乳动物”。在泛型语境下,如果一个类型构造器 `F` 是协变的,那么 `F` 可以被视为 `F` 的子类型,前提是 `SubType` 是 `BaseType` 的子类型。

逆变(Contravariance):允许我们将基类型实例传递给期望派生类型实例的地方。这听起来有点反直觉,但设想一下,如果一个函数接受一个“接受哺乳动物”的对象,那么传入一个“接受狗”的对象似乎是更安全的,因为“接受狗”的对象能处理所有“哺乳动物”,所以它自然也能处理“狗”。在泛型语境下,如果一个类型构造器 `F` 是逆变的,那么 `F` 可以被视为 `F` 的子类型,前提是 `SubType` 是 `BaseType` 的子类型。

不变(Invariance):介于两者之间,不允许任何形式的子类型替换。这意味着 `F` 和 `F` 之间没有直接的子类型关系,即使 `SubType` 是 `BaseType` 的子类型。

现在,类型推断是如何巧妙地驾驭这些复杂性的呢?答案在于,类型推断系统并不是孤立地工作,它会结合以下几个关键机制:

1. 基于赋值和函数调用的类型兼容性检查:

这是类型推断的基础。当我们在代码中进行赋值操作,例如 `BaseType variable = new SubType();`,类型推断系统会检查 `SubType` 是否能被安全地赋值给 `BaseType`。如果能,它就推断出 `variable` 的类型是 `BaseType`。

在泛型中,这种检查会延伸到泛型类型参数。例如,考虑一个简单的容器类型 `Container`。如果我们在一个函数中写 `Container dogs = new Container();`,并且 `Container` 的类型参数 `T` 是不变的,那么这个赋值就是不允许的,类型推断会报错。反之,如果 `Container` 的类型参数被声明为协变(通常通过 `out T` 这样的关键字表示),那么这个赋值就是合法的。

2. 引入类型变量和约束(Type Variables and Constraints):

为了处理泛型中的协变和逆变,类型推断系统常常会引入类型变量。这些类型变量代表了在特定上下文中未知的具体类型。同时,我们会为这些类型变量添加约束,告诉推断器这些类型变量必须满足的条件。

假设我们有一个函数 `processCollection(Collection items)`。当调用 `processCollection(new List())` 时,类型推断会尝试推断 `T`。在这种情况下,最自然的推断是 `T` 是 `Dog`。

现在考虑一个更复杂的场景,我们有一个函数 `findFirst(List items)`,它返回列表的第一个元素。如果 `items` 是 `List`,那么推断 `T` 为 `Dog`,并返回 `Dog` 类型的值是直接的。

但如果函数是 `processAnimal(Animal animal)`,我们调用它时传递的是 `Dog` 对象,即 `processAnimal(new Dog())`。这里,类型推断系统知道 `Dog` 是 `Animal` 的子类。如果 `processAnimal` 是用于处理“哺乳动物”的,那么传递一个“狗”对象是完全没问题的,这体现了协变的概念。

3. 联合类型和最小共同超类型(Union Types and Least Common Supertype):

在某些泛型场景下,类型推断需要考虑多个可能类型。例如,一个函数可能接收一个列表,这个列表的元素可以是“狗”或“猫”。在这种情况下,类型推断需要找到一个能够包容“狗”和“猫”的共同类型。

如果一个函数签名是 `processAnimals(List animals)`,并且我们传入了一个包含 `Dog` 和 `Cat` 对象的列表(虽然直接传递混合列表不太常见,但想象一下它的场景)。类型推断器会查找 `Dog` 和 `Cat` 的最小共同超类型,在这个例子中就是 `Mammal`(或者更通用一点的 `Animal`)。因此,类型推断可能会将 `T` 推断为 `Mammal`,并认为传入的列表的实际类型是 `List`(或者 `List`),尽管实际传入的是 `List` 和 `List`。

4. 显式类型标注和上下文推断:

虽然类型推断旨在减少手动标注,但它并非万能。当推断结果不明确或需要强制指定行为时,程序员可以通过显式类型标注来指导推断过程。例如,在 C 中,我们可以在泛型方法调用时指定类型参数:`myFunction(myDogList)`。

此外,类型推断还会利用上下文信息。例如,如果一个函数返回一个 `List`,并且我们将其赋值给一个 `List` 变量,那么类型推断会尝试找到一个既能满足返回类型,又能符合赋值目标类型的共同超类型。

5. 泛型类型参数的可变性声明(Variance Annotations):

许多语言(如 C)在定义泛型类型时,允许开发者通过特定的关键字(如 `in` 表示逆变,`out` 表示协变)来明确指定泛型类型参数的可变性。

例如,对于一个接口 `IEnumerator`,`out T` 声明了 `T` 是协变的。这意味着,如果 `Dog` 是 `Animal` 的子类,那么 `IEnumerator` 可以被当作 `IEnumerator` 使用。类型推断在解析 `foreach` 循环或 LINQ 查询时,能够利用这些可变性声明来做出正确的类型推断,确保类型安全。

同样,对于一个接收者接口 `Action`,`in T` 声明了 `T` 是逆变的。这意味着,如果 `Dog` 是 `Animal` 的子类,那么 `Action` 可以被当作 `Action` 使用。类型推断在处理事件订阅或回调时,会考虑这种逆变关系。

总结:

类型推断解决协变和逆变问题,并非依赖单一的魔法,而是通过一套协同工作的机制:

基础的类型兼容性检查:确保赋值和方法调用的合法性。
引入类型变量和约束:为未知类型设定规则,使得推断更加精确。
最小共同超类型查找:处理涉及多种类型的场景,找到一个包容性的类型。
利用上下文信息:从代码的整体结构中获取线索。
开发者提供的显式标注:作为最终的指导,弥补推断的局限。
泛型类型参数的可变性声明:让开发者明确告知类型系统其行为模式,从而指导推断。

通过这些精密的机制,类型推断系统能够在复杂的泛型和继承关系中,自动、安全地推断出最合适的类型,从而在保证类型安全的前提下,极大地提升了开发效率和代码的可读性。它就像一个勤恳的侦探,通过收集代码中的各种线索,最终锁定目标类型的真相,而无需开发者事事亲为。

网友意见

user avatar

轮百万和 @Ivony 讲的都太简单,其实协变逆变本身和C#什么的没什么关系,理论上来说协变逆变可以搞得很完备很复杂,复杂到以绝大部分人的脑力不能很快看出两个类型之间有没有协变逆变的关系。假如你准备好了可以看这里:


Programming Languages by Dan Grossman at Coursera.org


Section 8其中一部分是在讲Subtyping,基于一种自定义的也是最基本的Record类型(即 { a: { b: t1, c: t2 }, d: t3 } 这种)怎么判断类型之间是否兼容。视频的话可以从“Subtyping From the Beginning”部分开始看,看到“Depth Subtyping”,也可以直接看Reading Notes。


当然之前说了,因为这个判断实在太复杂,所以在真正使用的时候语言基本都是做了限制的,例如C#中只对interface和delegate提供了协变逆变。


此外,实现时C#也根据.NET的特性进行了限制,就像 @Ivony 说的那样,C#支持协变逆变的原则是“IL代码完全一致”。这导致了一点就是,虽然struct类型也是可以赋值给object的,但是IEnumerable<int>不能赋值给IEnumerable<object>,因为在使用IEnumerable<int>时的代码和IEnumerable<object>不同,而IEnumerable<string>和IEnumerable<object>是完全相同的。



总之理论是一回事,实现是另一回事情,是要考虑到程序员是否可以接受以及实现起来是否方便的。另外假如有兴趣的话,可以试着写一段程序,基于代码里的Record类型判断两个Record是否兼容,对编程能力是挺好的锻炼。

user avatar

C#的协变和逆变本质上就是通知编译器忽略没有必要的类型检查。


如果你看IL就会发现,对于:

       ((object) str).ToString();//str是string类型     

       ((object) obj).ToString();//obj是object类型     

或者

       obj.ToString();//obj是object类型      

这几个表达式生成的IL没有区别的,也就是说这里的类型转换其实没有真正的转换,仅仅只是确保调用的ToString方法是object声明的那个。


这也就是派生类对象可以直接当作基类对象使用无需额外的指令。


我当初问 @装配脑袋 C#是如何处理协变和逆变的时候,他说:不需要处理,假装那个对象是这个类型就好了。

类似的话题

  • 回答
    类型推断机制,在许多现代编程语言中扮演着至关重要的角色,它能够自动确定变量、函数参数和返回值的类型,从而简化代码编写,减少类型错误。然而,当涉及到泛型和继承关系时,一个棘手的挑战便出现了:如何处理“协变”和“逆变”这些与类型安全息息相关的概念。首先,我们得明白协变和逆变到底是怎么一回事。想象一下,我.............
  • 回答
    这个问题触及到了编程语言设计、编译原理以及现代语言生态的几个核心点,理解起来需要一些深入的探讨。直接说“Ruby 加上类型推断就能直接编译成二进制”,这是一种过于简化的说法,忽略了很多中间环节和技术上的挑战。首先,我们得明白,“类型推断”本身并不直接等同于“编译到二进制”。类型推断是一种在编译时(或.............
  • 回答
    近十年来的编程语言,确实观察到了一种趋势:变量声明时,倾向于将变量名放在前面,后面跟着类型声明。这种“变量名类型”的模式,相对于更早期的“类型变量名”模式,比如C、Java、C++等,在很多新晋语言中成为了主流。这背后并非是简单的“喜好”,而是一系列设计哲学和实践经验的演进,旨在提升代码的可读性、编.............
  • 回答
    在编程语言的世界里,如何声明变量的类型,是一个常常引发讨论的话题。这其中,类型前置(Type Prefixing)和类型后置(Type Suffixing)是两种最主流的风格,它们各自承载着不同的设计理念和实践考量。理解它们的优缺点,有助于我们更深入地理解语言设计哲学,并在实际开发中做出更明智的选择.............
  • 回答
    咱们来聊聊给编程语言加一种“计量”的基础数字类型,这可不是简单增添一个“float”或“int”的事儿,它涉及的是数字如何承载“单位”信息,以及这种信息如何在代码里流通、计算。设想一下,如果数字不再是孤零零的数值,而是自带了单位的标签,这能省多少事,又能避免多少坑。计量类型的设计思路核心思想是让数字.............
  • 回答
    对于从未接触过编程的妹子来说,选择合适的项目是学习编程的关键。以下是一些适合初学者的项目类型,按难度和学习目标分类,帮助你从基础到进阶逐步掌握编程技能: 一、基础编程入门项目目标:熟悉编程语法、逻辑和基本概念 推荐语言:Python(语法简单,适合新手) 1. 简单计算器 功能:实现加.............
  • 回答
    生活中的事物,你想让它是什么样子,它基本上就得是什么样子,比如你想让桌子长得方方正正,它就得方方正正,你不可能指望它突然长成一个圆柱体。编程语言里的变量类型,说白了,就是给数据规定一个“形状”,或者说“属性”,让它按照我们设定的规则来运作。没有这个“形状”的概念,计算机就像一个完全没有概念、什么都混.............
  • 回答
    解读前端响应式编程:优雅处理数据流,后台亦有其身影前端的响应式编程,近些年来的确是风头正劲,它像一股清流,为复杂的前端应用带来了更为优雅和直观的数据管理方式。简而言之,响应式编程的核心在于将数据视为一系列随时间变化的事件流,并通过声明式的方式来组合、转换和响应这些事件流,从而构建出更加灵活、可维护的.............
  • 回答
    你这个问题问到点子上了!很多初学者刚接触单片机,比如STM32,都会纠结于“裸奔”和“RTOS”之间的选择,觉得RTOS听起来很厉害,但又不知道具体好在哪儿,是不是真的比直接写代码(也就是你说的“裸奔”或“裸跑”)要强一大截。这么说吧,不是“有没有”优势,而是“有多大”优势,而且这个优势是需要具体场.............
  • 回答
    在函数式编程中,“继承”这个概念,如果严格按照面向对象中类与类之间血缘关系那种方式来理解,确实是没有直接对等的机制的。函数式编程更强调组合和转换,而不是“是什么”的层层递进。那么,当我们在面向对象的世界里需要“复用”和“扩展”某个对象的功能时,函数式编程是怎么做到的呢?想象一下,我们有一个基础的“行.............
  • 回答
    好的,明白了。我将为你介绍一些类似易百教程、W3Cschool和菜鸟教程这样,以实例为主、内容详实的在线编程学习网站。这些网站不仅提供理论知识,更注重通过实际的代码例子来帮助学习者理解概念,并动手实践。在众多的在线学习平台中,有几个佼佼者以其高质量的实例教程和全面的技术覆盖而脱颖而出。它们是程序员入.............
  • 回答
    Prolog 作为一种逻辑式编程语言,在学术界和特定领域(如人工智能、自然语言处理、专家系统、数据库查询等)有着深远的影响和不少忠实的支持者,但它确实没有像 C、Java、Python 那样成为一种主流的、被广泛应用的通用编程语言。这背后有多方面的原因,我们可以从以下几个维度来详细探讨: 1. 编程.............
  • 回答
    这事儿啊,说起来,跟哪个圈子都差不多,总会有那么点“看不起”的说法。编程语言这事儿,也是一样,你说有没有鄙视链?那可真是有点意思,也挺现实的。你瞧,最早的时候,大家玩得都是汇编,那是真硬核,直接跟机器打交道,一点错都不能有,不然就是一堆乱码。那会儿,能把汇编玩明白的,那绝对是神仙级别的人物,其他人见.............
  • 回答
    编程语言的魅力,很大程度上体现在它们孕育出的那些改变世界的杰出产品上。这些产品,或改变了我们的生活方式,或重塑了我们获取信息的方式,或让我们得以探索未知的领域。每一种成功语言背后,都有着一段与之相辅相成的产品故事。C 语言: 谈及 C 语言,你首先会想到的,很可能是它构建的 操作系统。从最初的 Un.............
  • 回答
    关于转义字符在不同编程语言之间是否通用这个问题,答案是:并不完全通用,但存在一些普遍的共识和常见的约定。想象一下,编程语言就像不同的国家,它们有自己独特的语言规则和语法。转义字符,就像是这些语言中的“特殊词语”,用来传达一些不能直接用普通字母或符号表示的意思。为什么会出现转义字符?首先,我们需要理解.............
  • 回答
    Brainfuck(BF)是一种极简主义的编程语言,由Urban Müller于1993年设计,只有8个命令和一个简单的内存模型。它以“极简主义”和“挑战性”著称,常用于测试程序员的极限或作为教学工具。以下是关于Brainfuck学习资料、下载资源和相关工具的详细指南: 一、Brainfuck的基本.............
  • 回答
    作为一个在代码世界里摸爬滚打过不少年头的人,被问到“最推荐哪几门语言”这个问题,确实挺让人感慨的。这就像问一个厨师最喜欢用哪几把刀一样,每把刀都有自己的脾气和用武之地。不过,如果真的要我从杂乱的工具箱里挑出几把最顺手、最能应对各种场景的,我大概会推荐下面这几门:1. Python:万金油中的战斗机,.............
  • 回答
    这就像问为什么你学会了一门方言,再去学一门全新的语言,会感觉磕磕绊绊,而不是顺理成章一样。归根结底,编程语言不仅仅是词汇和语法的不同,更深层的是它们背后一套理解和解决问题的方式,一种看待世界、分解任务的“哲学”。我们不妨从几个维度来拆解一下这个问题: 一、 抽象与封装的层次与方式不同每种编程语言都提.............
  • 回答
    假设真的发生了一个不可思议的瞬间,所有编程语言在执行 `0.1 + 0.2` 时,都能精确地返回 `0.3`,那么这件事的影响,绝不会像我们表面上看到的那么简单。这绝不是一句“皆大欢喜”就能概括的,它会像在平静的湖面投下一颗深水炸弹,掀起滔天巨浪,重塑我们对计算的认知,甚至动摇整个数字世界的根基。首.............
  • 回答
    电脑执行编程语言,这可不是件小事,里面门道可深着呢!打个比方,编程语言就像是人类沟通的语言,比如英语、中文。但电脑听不懂咱们这些语言,它只认一种极其原始、非常直接的语言,那就是“机器码”。你可以想象成一堆0和1的组合,每一串01就代表一个最基本的操作,比如“加法”、“移动数据”等等。那么,我们写的C.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有