GO基础:类型系统

542 阅读15分钟

「本文正在参加金石计划

初识类型系统


Go有什么类型

  • 有名字的——命名类型

    • Go提供的预声明简单类型:int、float、bool、rune、string、error......
    • 自定义数据类型:type new_type old_type
  • 没名字的——未命名类型

    数组(array)、切片(slice)、字典(map)、结构体(struct)、接口(interface)、函数类型(function)、通道(channel)它们在类型声明的时候都不是用自己本身的名字,而是采用预声明类型/关键字+操作符,比如——var hashMap map[string]int。因为它们的类型名字与声明的形式不一致,因此未命名类型也被称为类型字面量

  • ⚠️小总结——为什么未命名类型没有名字? 因为未命名类型类似是“容器”,你往里面放什么东西是不一定的,只有开发者在使用的时候才知道。并且Go是强类型的,这意味着变量在编译的时候就得知道类型,而这类“容器”没有办法被提前定义。

    未命名类型没有被赋予一个名称是因为它们通常是临时的、仅用于某个具体场景的类型。这些类型没有自己的名称,只是被定义为一种数据类型,并且可以作为其他类型的底层类型使用。在某些情况下,使用未命名类型可以使代码更加简洁和易读。

Go的结构体与函数

  1. 结构体与方法对于Go的重要性

    结构体与方法是Go面向对象的核心。结构体表示的数据类型,描述的是数据本身;方法表示的是数据的行为,描述的是行为本身——由此可见,结构体是有行为的数据,而方法是有数据的行为。 但是Go的面向对象与Java有着些许差异。Java面向对象的逻辑是“我是你”(继承),而Go的逻辑是“我需要你”(组合)。

    Go语言选择组合而非继承,是因为它遵循了SOLID原则中的“合成/聚合复用原则”(Composition/Aggregation Reuse Principle)。该原则强调,应该优先使用对象组合或聚合关系来达到复用的目的,而不是使用继承关系。

    使用组合关系实现代码复用,可以让代码更加灵活和可维护。Go语言中的组合关系可以通过结构体类型和嵌入字段的方式来实现。嵌入字段可以让一个结构体类型包含另一个结构体类型的所有字段和方法,从而实现了代码的复用。

    相比之下,继承关系容易造成代码的耦合和继承层次的过深,使代码变得难以理解和维护。此外,Java等语言中的继承还存在其他问题,例如可能破坏封装性、限制了类的灵活性等等。

  2. 自定义结构体

    我们可以通过定义结构体(Struct)来自定义一种新的数据类型,可以将不同类型的数据组合在一起,形成一个具有多个属性的复合类型,便于对数据进行操作和管理。

    type Person struct {
       Name string
       Age  int
    }
    
    • 初始化

      • 按照字段顺序初始化👎

        var p = Person{
         "Alice",
         20,
        }
        
      • 按照字段名初始化👍

        var p = Person{
         Name: "Alice",
         Age: 20
        }
        
      • 使用new初始化 —— 会将所有字段初始化为默认值

        var p = new(Person)

      • 一个一个字段初始化👎

        var p = Person{}
        p.Name = "Alice"
        p.Age = 20
        
      • 使用构造函数初始化👍👍👍(下面是标准库中errors的New函数示例)

        func New(text string) error{
         return &errorString(text)
        }
        ​
        //errorString is a trivial implementation of error
        type errorString struct{
         s string
        }
        

        当结构发生变化的时候,构造函数能够屏蔽细节。

    • 匿名字段

      type A struct { ID int }
      type B struct { Name string }
      ​
      type C struct{
       A//类型作匿名字段
       *B//类型的指针作匿名字段
       Desc string//具体描述
      }
      ​
      func main(){
       var c = C{
         A:A{ ID:1 },
         B:&B{ Name:"Alice" },
         Desc: "Hello,world!"
      }
       fmt.Println(c.ID, c.Name, c.Desc) // 输出:1 Alice Hello,world!
      }
      

      注意:

      1. 命名冲突:如果匿名字段的名称与当前结构体中的字段名称相同,会发生命名冲突。此时,当前结构体中的字段会覆盖匿名字段,无法访问匿名字段中的数据。所以,在使用匿名字段时,需要避免与当前结构体中的字段名称相同,或者使用指定字段名的方式来访问匿名字段的数据。
      2. 可见性:匿名字段的可见性与结构体中的其他字段一样,可以通过首字母大小写来控制其在包内和包外的可见性。
      3. 初始化:在初始化结构体时,匿名字段需要按照嵌套的顺序进行初始化。如果嵌套的字段很多,可能会比较繁琐。可以通过结构体字面量的方式来初始化,也可以通过结构体构造函数来简化初始化过程。
      4. 未命名类型不能成为匿名字段。
  3. 函数
    func(parameter_list) return_type
    

    其中,parameter_list 是以逗号分隔的参数列表,每个参数都有一个类型和一个名字(可选),而 return_type 是该函数返回值的类型。例如,一个接受两个整数参数并返回一个整数的函数类型可以表示为:

    func(intint) int
    

    函数类型可以像任何其他类型一样被命名,也可以作为匿名类型使用。

    函数类型的值可以是一个函数字面值,也可以是一个函数变量。函数字面值表示一个匿名函数,它可以直接使用,而无需定义一个函数名。

    例如,以下代码定义了一个名为 add 的函数类型,它接受两个整数参数并返回它们的和:

    type add func(intint) int
    

    现在,我们可以使用 add 类型定义一个函数变量并将其设置为一个函数字面值:

    var sum add = func(x, y int) int {
       return x + y
    }
    

    或者,我们也可以使用现有的函数并将其赋值给 add 类型的变量:

    func addNums(x, y intint {
       return x + y
    }
    ​
    var sum add = addNums
    

    在这个例子中,我们定义了一个名为 addNums 的函数,它与 add 类型兼容,因为它接受两个整数参数并返回一个整数。我们可以将 addNums 函数赋值给 add 类型的变量 sum,这意味着我们现在可以通过 sum 变量调用 addNums 函数。

⚠️自定义类型与别名

  1. 声明

    • 自定义类型:type NewType OldType
    • 别名:type NewType = OldType
  2. 本质区别
    1. 自定义类型是一个全新的类型,它有自己的底层类型和方法集,与原有类型没有直接关系。而类型别名只是给现有类型赋予一个别名,它们是同一种类型,在代码中可以互相替换。
    2. 自定义类型和底层类型之间没有隐式转换,必须使用显式转换才能相互转换。而类型别名和底层类型之间可以自动转换,因为它们是同一种类型。
    3. 自定义类型的零值是该类型的默认值,而类型别名的零值是底层类型的零值。
    4. 自定义类型和类型别名都可以定义方法,但是自定义类型的方法集只包括该类型的方法,而类型别名的方法集包括底层类型的方法集和该类型的方法集。

    总之,自定义类型是一种全新的类型,而类型别名只是给现有类型起了一个别名,它们在使用方式、转换规则等方面都有所不同。

  3. ⚠️自定义类型是什么类型——底层类型

    自定义类型仅仅相当于是用OldType的数据结构去创建了一个NewType,也就是说NewType除了样子很像OldType,其它哪里都不像,两者是不同的两个类型。

    在日常的情况下,我们会看到这种情况:

    type T1 string
    type T2 T1
    type T3 []string
    type T4 T3
    type T5 []T1
    type T6 T5
    

    那到底哪个才是它们的底层类型呢?Go是这样找的——自定义类型的底层类型,是逐层递归向下查找,直到查到预声明类型或类型字面量。

    也就是说:

    • T1T2的底层类型都是string
    • T3T4的底层类型都是[]string
    • T5T6的底层类型都是[]T1

    然而还有一个额外的点——:[]T1[]string是不一样的。这种差异的原因是因为在Go语言中,切片类型是由两部分组成的:元素类型和长度。虽然[]T[]string都表示一个字符串数组,但它们的元素类型是不同的,因此它们是不同的类型。

类型之间的赋值需要满足什么条件


  • 赋值的左边变量的类型与右边表达式的类型“相似”

    • 两边变量的声明方式相同

    • 有一边是可以隐式转换的

      type MyInt int
      var a MyInt = 8
      var b int = a
      

      在这个例子中,我们定义了一个类型别名 MyInt,其底层类型为 int。我们创建了一个变量 a,其类型为 MyInt,并将其赋值为 42。然后我们创建了一个变量 b,其类型为 int,并将 a 赋值给它。由于 MyIntint 的底层类型相同,因此可以将 a 隐式转换为 int,并将其赋值给 b

      同样,如果我们有一个变量的类型是未命名结构体类型,而另一个变量的类型是该结构体类型的底层类型,则这两个变量的类型也可以进行隐式转换。例如:

      type Person struct {
      name string
      age int
      }
      ​
      var p Person = Person{name:"Alice", age:30}
      var i interface{} = p
      

      在这个例子中,我们定义了一个结构体类型 Person,然后创建了一个变量 p,其类型为 Person,并将其赋值为具有姓名和年龄的结构体。接下来,我们创建了一个变量 i,其类型为 interface{},并将 p 赋值给它。由于 interface{} 可以包含任何类型的值,因此可以将 p 隐式转换为 interface{} 类型。

  • 右边表达式的值在内存中被分配到的“内容”

    var b int = 42
    var p Person = Person{name: "Alice", age: 30}
    

    42Person{name: "Alice", age: 30}都已经是常量,这种被称为字面常量,这些内容都会被自动分配到一块实际的内存中,当然能够直接赋值。

类型怎么绑定方法


什么是方法

方法是一种特殊函数,它需要一个对象实例或指针作为自己的第一个参数,也称这个对象实例或指针为接收者

//方法的接收者为对象实例
func (t TypeName) MethodName(ParamList) (ReturnList){ //函数体 }//方法的接收者为指针
func (t *TypeName) MethodName(ParamList) (ReturnList){ //函数体 }
  • 只能为自定义类型添加方法
  • 只能与类型声明在同一个包下注册方法
  • type new_type old_type中,新类型只继承旧类型的操作集而非方法集
  • 方法的可见性与变量的规则一样

方法调用

  • 几种调用方法
    • 一般调用 —— 不会严格检查接收者的类型,会自动转换接收者类型

      type A struct{}
      func (a A) Get(){}
      func (a *A) Set(){}
      ​
      var a = A{}
      a.Get()
      (&a).Set()
      
    • 方法值调用 由于函数本来就是一种类型,那它也可以被赋值到某一个变量上

      type X struct{}
      func (x X) DoSomething(){}
      ​
      var x = X{}
      f := x.DoSomething()
      f()
      

      这个变量f将会保存DoSometing的的方法表达式与接收者x,在调用f的时候,其实就是将x作为接收者传递给方法表达式DoSomething

    • 方法表达式调用 通过将方法与接收者类型的组合放在一起,创建一个方法表达式。例如,假设我们有一个结构体类型Person和一个方法SayHello

      type Person struct {
         Name string
      }
      ​
      func (p Person) SayHello() {
         fmt.Printf("Hello, my name is %s\n", p.Name)
      }
      

      我们可以使用如下方式来创建一个方法表达式:

      greet := Person.SayHello
      

      这里,Person.SayHello 返回一个函数类型,它的签名与方法的签名相同,除了第一个参数,即接收者。接着,我们可以将该函数赋值给一个变量 greet,并像普通函数一样调用:

      p := Person{Name: "Alice"}
      greet(p) // Hello, my name is Alice
      

      在方法表达式中,接收者参数被视为普通参数,我们需要手动传递接收者对象。这使得我们可以在函数中使用任何类型的接收者,而不仅限于方法定义中指定的类型。

      值得注意的是,方法表达式可以用于非结构体类型的方法,只要这些方法被定义为该类型的方法。例如,对于类型 int,我们可以创建一个方法表达式来调用它的方法:

      abs := math.Abs
      x := -2.5
      fmt.Println(abs(x)) // 2.5
      
  • ⚠️方法集

    方法集指的是一个类型中可以被调用的方法的集合。

    方法集包括两种类型的方法:值接收者方法和指针接收者方法。一个类型的方法集中只包含这两种类型的方法。

    具体而言,对于一个给定的类型T,它的方法集包括以下两种类型的方法:

    1. 值接收者方法:形如func (t T) methodName(args) returnType的方法。对于值类型变量和值类型指针变量都可以使用。

    2. 指针接收者方法:形如func (t *T) methodName(args) returnType 的方法。只能使用在指针类型变量上。

    当我们定义一个类型T的方法时,如果该方法使用了值类型接收者,则类型 T 的值和指针都可以调用该方法;如果该方法使用了指针类型接收者,则只有类型T的指针才能调用该方法。

    在实践中,使用值类型接收者的方法可以用来操作值类型变量和值类型指针变量,而使用指针类型接收者的方法则通常用于避免在方法调用中进行大量的值复制,提高程序效率。

⚠️两个不同的接收者的含义


逻辑意义

  1. 接收者为实例:某个对象完成一件事,这件事不会改变自身的状态 比如:一个学生完成作业
  2. 接收者为指针:某个对象不但要完成一件事,还要改变自身的状态 比如:一个小孩饿了就去吃完饭,然后ta饱了,不想再吃东西了

程序意义

方法和函数的实参的传递都是值拷贝。如果接收者是值类型,则传递的是值的拷贝;如果接收者是指针类型,则传递的是指针的拷贝。但是指针相对于值最大的不同在于,指针的值是存着一块真实存在于内存中某一块的地址,这使得通过指针能修改那块地址所在的空间的状态,而单纯拷贝了那块空间上的值是无法产生此效果的。

需要注意的点

  1. 一般调用的时候,Go语言会自动将值类型的接收者转换为指针类型,以符合指针类型方法的接收者类型要求。这个转换是通过隐式获取t的地址来实现的。因此,在Go中,即使一个方法的接收者是指针类型,我们也可以通过一个值类型的变量来调用该方法,而不必手动获取其地址。

  2. 在使用值调用与方法表达式的时候,本质上都是将接收者传给方法表达式,而方法表达式是显式地声明接收者的类型的,因此在调用的时候也会严格检查接收者的类型。

  3. 结构体内包含匿名字段的时候

    package main
    ​
    import "fmt"type A struct{}
    ​
    func (a *A) APtrFunc() {}
    func (a A) AValueFunc() {}
    func (a A) Test(){}
    ​
    type B struct{ A }
    ​
    func (b *B) BPrtFunc() {}
    func (b B) BValueFunc() {}
    func (b B) Test() {}
    

    在这个例子中,我们定义了两个类型 AB,其中 B 包含了一个匿名字段 AA中包含APtrFunc()AValueFunc()方法,B中包含BPtrFunc()BValueFunc()方法,并且两个类型都定义了一个名为 Test() 的方法。

    在B结构体内加入A的匿名字段后,B的方法集会产生扩展,具体变化如下:

    1. 此时B会包含A非指针类型的方法集
    2. B嵌入的是*A,则B的方法集会包含A的所有方法集
    3. 不论B嵌套的是A还是*A*B的方法集都会包含A所有方法集。

    当一个结构体类型内嵌了一个匿名字段,匿名字段也可以有方法集。在这种情况下,方法集会包含结构体类型的方法集和匿名字段类型的方法集。

    当一个结构体类型内嵌了一个匿名字段,匿名字段也可以有方法集。在这种情况下,方法集会包含结构体类型的方法集和匿名字段类型的方法集。

    具体来说:

    1. 如果匿名字段是一个命名类型,那么方法集将包含该类型的值方法集。
    2. 如果匿名字段是一个未命名类型,那么方法集将包含该类型的指针方法集。

    如果匿名字段和结构体类型有同名方法,那么匿名字段类型的方法将覆盖结构体类型的方法。如果匿名字段有多个类型,它们中的方法将彼此合并。因此,如果两个匿名字段类型都有同名方法,则会发生方法集的混淆。此时,必须通过显式调用来指定要使用哪个类型的方法。