编程语言中的类型系统

Dec 25, 2022 16:30 · 3812 words · 8 minute read

原文:https://www.tektutorialshub.com/programming/type-systems-in-programming-languages/#type-compatibility


编程语言中的类型系统,决定了我们如何声明、使用和管理类型。

什么是类型?

类型,又或是数据类型,是一种数据的属性,告诉我们数据有哪种类型的值。定义了它能接收的一组值还有我们可以对那些值进行的操作集合

绝大部分编程语言中的 int 类型代表整数,存储从 -2147483648 到 2147483647 之间的任意值。还有 +- 是我们能够对其所做的有效操作

大多数编程语言都定义了一些内置类型比如,整数(integer),十进制数(decimal),字符串(string),布尔值(boolean)等等。它们也允许用户向系统中添加新类型,可以用内置类型和其他复杂类型的组合来创建复杂类型。

类型决定了:

  1. 对其进行的操作
  2. 该类型变量所需的存储空间
  3. 能够在该类型中存储的值范围
  4. 它是如何继承的;它的基类型
  5. 它实现的接口

什么是类型系统?

类型系统是由一系列规则组成的逻辑系统,将称作类型的属性分配给计算机程序的多种结构,如变量、表达式、函数、模块等。

类型系统管理类型。每种类型都有一系列规则。类型系统确保我们正确地使用类型,遵守规则。

每种编程语言都有类型系统,被构建在解释器或编译器或运行时中。这些类型系统的工作方式因编程语言而异。

类型系统的必要性

类型系统隐藏了处理类型的复杂性。我们无需担心计算机如何存储数据,如何解释以比特为单位的数据,我们只要遵循规则来使用它就行了。它还会在编译或运行期间检测类型错误,减轻我们的心智负担。

抽象实现的细节

类型系统使得程序员在更高的层级上思考数据而非比特和字节。

计算机以二进制(0 或 1)存储任意数据。数字 65 被表示为 1000001;字符通过 ASCII 码来转换成数字。而字符 A 的 ASCII 码也是 65,因此数字 65 和字符 A 在内存中有些相同的表示 1000001。

当我们的代码从计算机内存中读取到值 1000001 时,如何确定它代表的是字符还是数字?唯一的办法就是将值的类型同时存下来,类型系统会为我们处理。如果没有类型系统,我们将要自己来追踪数据的类型。

另一个例子是 + 操作符的使用,我们用它来做数学加法和连接字符串,实际上它们是两种截然不同的操作。这里编译器(或解释器)使用类型系统来检测类型并应用正确的操作:如果它们是数字就做加法;反之则连接字符串。

let num1=10
let num2=10

console.log(num1+num2)   //20               Numbers are added

let str1="Hello"
let str2="world"
console.log(str1+ str2)  //HelloWorld       Strings are joined

检测错误

类型错误当代码执行对该类型无效的操作时抛出。一个健全的类型系统可以在编译或运行时检测各种逻辑错误,称为类型错误或类型不匹配。例如,将两个布尔值数学相加这样的无效操作必须导致编译或运行时的错误。

在编译时就检测类型错误非常有用,因为我们可以快速修复这些错误。

文档

附加在变量上的类型提供了关于代码的宝贵信息,充当了代码的文档。另一方面,每当代码变更时,注释需要更新。随着时间的推移可能会断更。但类型一直会保留下来,结合数据的类型和用法来更容易理解和推测代码的目的。

类型检查

类型检查是验证和执行类型规则的处理。例如,检查除法运算的两个操作数的类型以确保它们都是数字且除数不为零。

类型检查可以在编译或运行期间发生。因此,我们将类型系统分类为静态类型(在编译时检查类型)和动态类型(在运行时检查类型)。

静态类型检查

静态类型检查在编译期间发生。它为我们提供了关于类型错误的早期反馈,是我们能够迅速修正这些问题。在静态类型检查中,需要指定我们使用的每个变量的类型。我们可以明确地这样做(显式类型),或者让类型系统从上下文中推断出来(隐式类型)。因为在编译时可以使用类型信息,编译器就可以生成优化的代码。还消除了在运行时检查类型的需要,使得可执行文件运行得更快。

有静态类型检查系统的语言:C、C++、Java、C#、Scala、Haskell、Rust、Kotlin、Go 和 TypeScript

动态类型检查

动态类型检查在运行期间发生。类型只在它们被使用前检查。因为可以给变量分配不同类型的值,这让代码变得很灵活。

但是动态类型检查会使代码变慢。只有在运行代码时才能检测到类型错误。这是个麻烦,因为我们不可能测试所有可能的情况。你可能要写大量单元测试来消除大部分的类型错误。

动态类型检查的语言:JavaScript、Ruby、Python、Perl、PHP、Lisp、Bash

类型声明

类型类型检查系统需要预先指定类型。有两种方法可以为一个变量指定类型,一种显式一种隐式。

明确的类型

在显式的类型系统中,我们需要在声明变量时指定类型,例如 C# 和 Java:

int num = 100;

隐含的类型

在隐式的类型系统中无需在声明变量时指定类型。类型系统会从变量的使用中推断出类型。但只有在自动推断失败时,它们才需要 明确的声明。

TypeScript 的类型系统是隐式的:

let num = 100;

这个例子在没有指定任何类型的情况下声明了 num。TypeScript 会从用法中推断出类型。注意我们也可以在 TypeScript 中显式地指定类型。

类型安全

类型安全是类型系统预防类型错误和不安全行为的能力。比如,当咱们的数据是 X 类型的,并且 X 不支持操作 y,然后语言就不允许我们执行 (y(X))

访问到不该访问的内存或执行“不可能”的操作,就像除零等等,都是一些不安全行为。

C++ 就是非常不安全的语言,我们可以解引用 NULL 指针越界访问数组使用未初始化的变量等等。

类型系统在检测到不安全行为时抛出运行时异常,那就没问题。例如 C# 就有一个相当安全的类型系统,会抛出 Null 指针异常数组越界异常 并且不允许使用未初始化的变量。

语言必须要成为类型安全的,必须从出生到死亡管理好值:

  1. 以一种安全的方式创建和初始化对象
  2. 确保程序在对象的声明周期内不会损坏它们
  3. 确保我们遵循类型的规则来使用类型
  4. 最后应该销毁对象并以一种安全的方式回收内存

强弱类型

类型系统也可以分为强类型弱类型,但没有明确的定义。

一般来说,公认的强类型系统:

  1. 变量必须有类型,而且不能更改这个类型。咱们要预先显式或隐式地指定变量的类型。
  2. 语言不会隐式地改变任何变量的类型。我们必须显式地进行所有类型转换。
  3. 如果类型兼容,就不能直接操作。即使咱们有一个用字符串表示的数字,也不能把它作为加法运算中的操作数。要使用它就需要显式地将字符串转换为数字。

这会使得代码变得啰嗦;但也使得代码更容易理解,因为没有暗戳戳的行为。

有静态类型检查的强类型语言包括 Java、Pascal、Ada、C 和 C#。另一方面 Python 有动态类型检查和强类型。

在弱类型的语言中,变量可以根据我们的用法改变其类型。如果需要运行时也可以进行隐式的类型转换。你可以传一个字符串作为除法运算的操作数,运行时会隐式地将字符串转换为数字。如果转换失败,可能会导致错误(更糟糕的是无效结果)。

JavaScript 是弱类型语言的典范。下面代码中数字与字符串相除,不会抛出任何错误,但是操作会以 NaN(Not a number)结果失败。

console.log(100/"Hello")   //NaN
console.log(100/"10")      //10

类型兼容

类型的兼容性指的是两种不同类型之间的相似性。类型系统通常被分为结构化类型、名义类型和鸭子类型,取决于它们如何比较类型的兼容性。

想想原始数据类型 integerdecimal。咱们可以轻易地将整数转换为十进制数值而不丢失精度。因此,我们可以说整数和十进制数据类型是兼容的。但反着就不行,不可以将十进制转换为整数类型。这样做会导致精度丢失。因此十进制类型和整型不兼容。

名义类型

在名义类型系统中,类型的兼容性是由明确的声明或类型名称决定的。每种类型在名义系统中都是独一无二的。即使它们的数据和样子相同,我们也不能跨类型分配它们。

在下面例子中,类型 DogCat 都有字段 name,因此它们的结构是相同的。在名义类型系统中从它们创建的对象 catdog 是不兼容的,因为咱们是从不同的类型中创建的。

public class Dog {
    public String name;
    public Dog(string name) {
        this.name=name;
    }
}

public class Cat {
   public String name;
   public Cat(string name) {
       this.name=name;
  }
}

Dog dog = new Dog("Pluto");
Cat cat = new Cat("Pluto");

结构化类型

在结构类型中,如果两个类型形似,则被认为是兼容的。

上面的例子,catdog 是兼容的因为它们形似。

注意类型 A 兼容 B 不代表 B 也兼容 A,上述例子中如果 Dog 有额外的属性,那它仍与 Cat 兼容,但是 Cat 不再与 Dog 兼容。有点像所有的橘子都是水果但并非所有的水果都是橘子。

鸭子类型

鸭子类型既不关心名字也不关心结构,它必须具有操作所需的给定方法或属性。我们只在动态类型语言中发现鸭子类型。

下面 JavaScript 示例包含两种对象 personbankAccount。它们啥联系也没有,但它们有一个相同的方法 someFninvokeSomeFn 函数毫无疑问接收这两个对象。实际上可以向 invokeSomeFn 传任何东西,只要那种类型有 someFn 就行

let person = {
    name:'Jon',
    someFn:function() {
        console.log("hello "+this.name + " king in the north")
    }
}

let bankAccount = {
    accountNo:'100',
    someFn:function() {
        console.log("Please deppost some money")
    }
}

invokeSomeFn= function(obj) {
    obj.someFn()
}

invokeSomeFn(person)
invokeSomeFn(bankAccount)

类型变更

类型变更指的是将一个变量从一种类型更改到另一种。有两种类型转换:一种隐式转换另一种显式转换。

隐式转换

隐式转换由编程语言自动完成。下面代码将数字与字符串相加,这里 JavaScript 没有抛出任何错误或警告。它只是将数字转换为其字符串表达并将字符串连接起来。

let a=10
let b="hello"

console.log(a+b)    //10hello

上述代码同样在 C# 和 Java 中奏效。这些语言确实允许隐式类型转换,但只有在能够保证在转换过程中无数据丢失才会进行。

同样的代码在 Python 中会以类型错误告终。因为 Python 不允许隐式类型转换。

a=10
b="hello"

print(a+b)


//TypeError: unsupported operand type(s) for +: 'int' and 'str'

可见隐式转换的规则因语言不同而不同,有些允许数据类型之间的隐式转换,有些会限制,每种语言都有自己的规则集。

显式转换

显式转换由程序员通过代码强制执行。在 Python 中咱们使用 str 函数将数字转换成字符串。

a=10
b="hello"

print(str(a)+b)

引用

  1. Type Systems in Software Explained
  2. Functional Programming Type Systems
  3. Type System