跳至主要內容

对象和类

LincZero大约 15 分钟

对象和类

(摘自“语言区别”一章) Go中的面向对象

与其他语言不同

各语言中,什么是类 / 对象 / 面向对象语言

什么是类?

(结合下一节一起看)

  • 类:我个人认为只要能将数据和方法绑定在一起,且能实现面向对象三大特性 (封装/继承/多态),都能称之为类。
  • Class类 (大多数语言,如C++、Java、C#):用 Class 关键字声明类,这是传统的类
  • Struct类 (C++/C、Go、Rust):有的语言用 Struct 当作类,或允许 (C++、Go) 或不允许 (C) 数据和方法定义在一起 (C++结构体在C的基础上支持了方法的定义,但似乎一般不叫这个为类,也一般不这样用)
  • 原型类 (JavaScript):一个对象的原型相当于实例化自己的类,原型链上有自己的类、父类及祖先类 (在ES6及以后的版本中,JavaScript引入了 class 关键字,它提供了一种更接近传统面向对象语言的语法糖,但本质上仍然是基于原型的)
  • 无类 (C):没有类的概念,通常不支持数据与方法的绑定写法,不支持继承等操作。 通常没有面向对象三大特性 (封装/继承/多态)。虽然强行用面向对象的方式写也没问题。但最多只能叫无类的面向对象。 强行写的话:由于数据和方法无绑定,通常根据文件和命名方式绑定。无权限控制,我习惯用 _ 结尾表示私有方法。继承上用组合替代继承。
  • 自定义类型类 (Go):Go的类不仅局限于Struct,自定义的类型通常都可以当作类来使用,都有面向对象的特性:可以继承任意类型、扩展类方法、实现接口

什么是对象?

(结合上一节一起看)

  • 对象:这个不同语言定义的就不同了。我认为只要能有模板个创建多个有相同特征变量的,都能叫对象。当然,这不总是对的。最简单的就是“基本类型”到底算不算对象
  • 类对象 / Class对象 (大多数语言,如C++、Java、C#):一般有Class关键字,支持类的继承,权限控制等。通过实例化Class类出来的东西才是类,而基本类型不是。对象是基于类的实例
  • Struct对象:略
  • 原型对象 (JavaScript):每个对象都有一个Prototype链,原型能继承,所有对象的共同根祖先是Object
  • 皆对象 (JavaScript、Python、Ruby):所有东西,乃至 int / 函数 / 模块 都属于对象
  • 无对象 (C)

什么是面向对象语言?

  • 面向对象语言 /  类对象 (大多数语言,如C++、Java、C#)
    • 完全面向对象 (Java):根部只有类定义,包括 main 函数要在任意一个自定义类里 (如果定义在多个类里,编译要指定,如 java ClassA)
    • 支持面向对象 / 多范式编程语言 (大多数语言,C++、C#、Go):支持面向对象、面向过程、函数式、过程式。根部可以有类、函数、基本类型。 其中 Go 其实和 Java 有些类似的理念:Go 的资源全在包中,包括 main 函数要在 main 包中。
  • 基于原型语言 /  皆对象 / 原型对象 (JavaScript)
  • 基于对象语言 /  皆对象 (Python、Ruby)
  • 非 class "类" (Go、Rust)
    • (Go):没有传统意义上的“类”概念,取而代之的是“类型”(Type)和“接口”(Interface)。
      • 类型:结构体可以包含字段(Field)和方法(Method),方法可以与结构体关联。
      • 接口:定义了一组方法签名。任何实现了这些方法的类型都可以被看作是该接口的实例。接口在Go中用于实现多态性,而不是通过继承实现
    • (Rust):没有传统意义上的类和对象,但它提供了其他机制来实现类似面向对象编程的功能
      • 结构体:Rust中的结构体类似于其他语言中的对象,可以包含字段和方法。
      • 枚举:不仅可以表示一组值,还可以为不同的枚举值定义不同的方法
      • 特质 (Trait):定义了一组方法签名,类似接口和多态性的功能
  • 面向过程编程语言 (如 C)
  • 函数式编程语言 (如 Haskell、Erlang):更侧重于函数和不可变数据结构。计算是通过纯函数的应用来进行的,而不是通过对象的状态变化
  • 逻辑编程语言 (如 Prolog):逻辑编程语言中的对象通常是指事实和规则,而不是传统意义上的对象。在Prolog中,程序是由一系列的事实和规则组成的,这些事实和规则可以被视为“对象”,它们通过逻辑运算来表达程序的逻辑。

“类”/结构体 (type ... struct)

与其他语言不同

Go 特色的面向对象:

  • Go面向对象功能

    • 去除了:Go的类去掉了传统OOP语言的:方法重载、构造函数、析构函数、隐藏的this指针等
    • 封装:保留但不同。方法不写在结构体内,访问权限作用域是包
    • 继承:保留但不同。没有extends关键字,通过匿名字段实现的
    • 接口:保留但不同。没有implement关键字,实现接口基于方法而非接口
    • 多态:保留但不同。并非通过继承关系的里氏替换原则,而是通过接口实现的,也就是所谓的鸭子方法
  • 自动添加解引用和取地址符

    • Go的底层编译器做了优化,对于调用方法的变量,编译器自动添加解引用和取地址符 (&* 符号,编译器操作,便于少写点东西。注意仅对调用方法的变量有效,对传入方法的变量是无效的。个人觉得这是个垃圾设计,违背单一写法原则、新增了语法、隐藏了原理,还不如编辑器/编译报错)
    • (*o).data 等同 o.data
    • (&o).fn() 等同 o.fn()
  • 任意类型绑定方法,组合继承

    • Go 的方法可以绑定在任意数据上,结构体、int、float32 等。 为此,需要先重命名,然后才可以绑定。类似扩展,属于组合继承,而非真继承

    • 这点和 JavaScript 很像,Js 万物皆对象,Js也能扩展number这种基础类型,但依靠的原型链机制

      可以通过原型链的方式扩展对象 Number.prototype.myMethod = function() {...};

      在ES6后,更可以用类语法糖 (但本质还是原型) class ClNumber extends Number

类型定义

方法和成员变量,通过首字母大小写进行访问权限控制

package main
import "fmt"

// 定义结构体
type Teacher struct {	// 居然需要用 type 关键字,这个关键字复用怪怪的,上次使用还是:type 新名 旧名
    Name string
    Age int
    School string
    
}

func main() {
    ...
}

声明定义、使用 (四种方式)

// 方式1:声明定义
var t1 Teacher
fmt.Println(t1)

// 方式2:声明定义,并初始化方式
var t21 Teacher = Teacher {"李四", 19, "AA大学"}  // 2.1 按顺序版
var t22 Teacher = Teacher {						// 2.2 按字段版
    Name: "李四",
    Age: 19,
    School: "AA大学",
}
var t23 *Teacher = &Teacher{}					// 2.3 可返回指针

// 方式3:new方式 (返回的是指针,前面的方式都是值类型)
var t3 *Teacher = new(Teacher)

// 可修改值
t1.Name = "张三"
t1.Age = 18
t1.School = "XX大学"

(*t3).Name = "王五"

t3.Age = 20 // 赋值语法糖。Go底层会先转地址再运行点运算符。居然不是用(->)符号
			// `(*t3).` 等同 `t3.` 感觉这个设计容易误导人

通用方法

这里是指不是 类方法、非自定义 方法

转换

  • Go的结构体相当于是用户定义的类型,和其他类型转化时需要有完全相同的字段 (名字、个数、类型)
  • 结构体通过 type 取别名 (相当于重新定义),Go认为是新的数据类型,但彼此可以强制转换
  • 有点类似于鸭子方法的判断
package main
import "fmt"

type Student struct {
    Age int
}

type Person struct {
    Age int
}

type Stu Student		// Go的别名会认为是两种不同的类型 (虽然可以互相强转)

func main() {
    var s Student
    var p Person
    var s2 Stu
    s = Student(p)		// 类型转换
    s = Student(s2)		// 类型转换
}

类方法

定义,自定义方法

类/结构体 没有自己的方法,需要自己定义

// 定义
type Person struct {
    Name string
}
func (p *Person) test() {	// 需要引用类型,才能改变值
    (*p).Name = "LiSi"		// 或可以简写成 p.Name (原因:编译器会自动加&*符号)
    fmt.Println(p.Name)
}

// 调用
var person Person
person.Name = "ZhangSan"
((&person).test()			// 这里几种写法都可以。写成 person.test() 也是对的 (原因:编译器会自动加&*符号)
// test(&person)			// 不能使用这种写法

为非结构体绑定方法

Go 的方法可以绑定在任意数据上,结构体、int、float32 等。

为此,需要先重命名,然后才可以绑定。类似扩展,属于组合继承,而非真继承。

非常神奇

package main
import "fmt"

type integer int

func (i integer) print() {
    fmt.Println("i = ", i)
}

func (i *integer) change() {
    *i = 30
    fmt.Println("i = ", *i)
}

func main() {
    var i integer = 20
    i.print()
    i.change()
    fmt.Println(i)
}

特殊方法 —— String()

如果一个类型实现了 String() 这个方法,那么 fmt.Println 默认调用该方法进行输出

底层原理

函数和方法区别

  • 定义与使用

    • 函数

      // 定义函数
      func fn(s Student) {	// 无需绑定数据类型
          fmt.Println(s.Name)
      }
      
      // 调用函数
      函数名(实参列表)
      
    • 方法

      // 定义方法
      func (s Student)fn() {	// 需要绑定指定数据类型
          fmt.Println(s.Name)
      }
      
      // 调用方法
      变量.方法名(实参列表)
      
  • 传入类型

    • 函数:参数类型是什么就传入什么
    • 方法:接收值为值或指针类型时,可允许传入值或指针类型 (原理:对于调用方法的变量,编译器自动增加解引用和取地址符。而对于传入函数的变量则不进行此操作)

扩展

跨包创建结构体

和其他语言的访问权限控制一样。结构体首字母要大写才能直接访问,否则也可以通过一个首字母大写的工厂方法来创建并返回该结构体

三大特性 (1) 封装

与其他语言不同

  • 私有作用域:Go私有的作用域是仅限同包使用,而大多数语言是仅限类内部使用 (优点:同一个包的各个类和函数相当于互为友元。缺点:如果我要写简易的独立类,想严格控制权限岂不是要一个文件一个文件夹一个包,太丑了)
  • 写法:其他语言语言是private之类的关键字,Go是命名的首字母大小写。写法好丑陋
  • 特殊函数:没有析构函数因为自动GC,没有构造函数但建议类名小写而通过自定义工厂函数构造。特殊函数似乎就只有 String() 函数

通用知识

  • What 核心:绑定数据和方法、以及权限控制。不过Go的封装不太严格
  • Why 好处:隐藏细节、访问控制
  • How Go如何封装:建议将结构体名和字段名均小写,工厂方法和 Set Get 方法大写

三大特性 (2) 继承

通用

与其他语言不同

  • 匿名结构体写法
    • 写法:没有extends关键字,通过匿名结构体实现的继承
    • 本质:本质是组合替换继承,调用上 子类.匿名结构体名.匿名结构体方法(可使用语法糖不写匿名结构体,简写:子类.匿名结构体方法
  • 作用对象:可继承自任意类型
  • ?别名并增添方法
    • 话说用 typeof 重命名再增加新方法,这种形式也可以吧。不过这种形式只能扩展方法不能扩展字段

通用知识

  • What 核心:is关系的复用
  • Why 好处:提高复用
  • How Go如何继承:没有extends关键字,通过匿名结构体实现的

写法举例

package main
import (
    "fmt"
)

// 父类
type Animal struct {
    Age int
    Weight float32
}

func (an *Animal) ShowInfo(){
    fmt.Printf("年龄%v, 体重%v\n", an.Age, an.Weight)
}

// 子类
type Cat struct {
    Animal
}

func (c *Cat) scratch(){
    fmt.Println("我是猫,可以挠人")
}

func main() {
    cat := &Cat{}				// 若初始化,写法也比较简洁 c := C{A{10,"aaa"}, B{20, "bbb, 50"}}
    cat.Animal.Age = 3			// 可简化为不写匿名结构体
    cat.Animal.Weight = 10.6	// 可简化为不写匿名结构体
    cat.Animal.ShowInfo()		// 可简化为不写匿名结构体
    cat.scratch()
}

注意事项

  1. 结构体可以使用嵌套结构体的公有字段和方法(同包情况下可以访问所有字段和方法)

  2. 函数寻址原理:可简化不写匿名结构体的字段。子类.匿名结构体名.匿名结构体方法 简写为 子类.匿名结构体方

    省略时,若子类和父类有同名字段或方法时,编译器采用就近访问原则。如果希望访问匿名结构体的字段和方法,则加上匿名结构体名

  3. 多继承:支持,但不建议使用(虽然他这个本质是组合啊,但问题在于匿名结构体和具名结构体字段在是否能省略结构体名上还是有区别的)

    冲突:在省略结构体名时,若两父亲有同名函数,编译器就近原则寻找父类名字时会编译报错。

    解决1:我的理解是可以有多个具名结构体字段,但匿名结构体最好只定义一个。

    解决2:但如果真需要定义多个匿名结构体,在访问时需要用匿名结构体类型名区分。

    解决3:干脆子类定义一个同名方法覆盖掉这个函数

  4. 作用对象:结构体的匿名字段可以是其他类型。用法有点诡异,不建议

    1. 可以是基本数据类型

      type ClassC struct{
          ClassA
          ClassB
          int			// 访问:c := ClassC{A{10}, B{"b"}, 888}; c.int == 888
      }
      
    2. 可以是结构体指针

      type ClassC struct{
          *ClassA
          *ClassB
          int			// 访问:c := ClassC{&A{10}, &B{"b"}, 888}
      }
      
  5. 非匿名结构体。匿名结构体和具名结构体还是有些不同的 (体现是否能缺省匿名结构体名)

    type D struct {
        a int
        b B		// 组合模式
    }
    
    // 访问
    d := D(10, B{666})
    fmt.Println(d.b.x)
    

接口 (type ... interface)

与其他语言不同

  • 作用对象:任意自定义类型均可实现接口

  • 没有implement关键字。Go实现接口并不声明某结构体或其方法实现了哪个接口,只能判断某结构体是否实现了某个接口

    ps. 挺怪的。优点是和接口的耦合性很低。缺点是岂不是说无法去强制别人去实现这某个方法?他不实现接口也不会报错?这点我认为不好

Go

  • 接口定义:定义一组不实现的方法,不定义变量。
  • 接口实现:需要实现接口定义的所有方法,才算实现了这个接口。无需声名该结构体或方法是实现了什么接口,接口判断的流程属于是 “鸭子方法”

类型定义

// 接口。定义规则、规范、能力
type SayHello interface{
    sayHello()	// 声明没有实现的方法
}

...

声明定义、使用

// 接口。定义规则、规范、能力
type SayHello interface{
    sayHello()	// 声明没有实现的方法
}

// 类A
type Chinese struct{
    
}

// 类A 实现接口的方法
func (person Chinese) sayHello(){
    fmt.Println("你好")
}

// 类B
type American struct{
    
}

// 类B 实现接口的方法
func (person American) sayHello(){
    fmt.Println("hello")
}

// 定义一个函数处理各国人打召唤的函数
func greet(s SayHello){		// 接收具有SayHello接口能力的变量
    s.sayHello()
}

func main() {
    c := Chinese{}
    a := American{}
    greet(c)
    greet(a)
}

注意事项

  1. 实例化:接口不能实例化,只能指向一个实现了该接口的自定义类型的变量(这的和Java接口或C++纯虚基类一样)

  2. 作用对象:自定义类型均能实现接口(非结构体也行)

    type SayHello interface{
        sayHello()
    }
    
    type interfer int
    func (i integer) sayHello(){
        fmt.Println("say hi + " + i)
    }
    
  3. 多接口:一个自定义类型可以实现多个接口

  4. 接口多继承:多一个接口可以继承多个别的接口。这时如果要实现接口A,也必须实现接口B、C

    冲突1:如果两个被继承接口有同名函数,函数签名完全相同的话,应该是不需要去管,实现一遍就行

    冲突2:但如果是同方法名不同签名呢?Go不支持函数重载,会冲突导致编译错误

    解决:需要InterfaceC中定义一个同名的方法,覆盖掉两个被继承接口里的同名方法

  5. 空接口:没有任何方法的接口 (即所有类型均实现了空接口)

    妙用:可以用该方法来定义接受任意类型的形参,做类似泛型的效果

    类似 JavaScript 中所有类的祖先都是 Object

  6. interface类型默认是一个指针,如果没对interface初始化则为nil

三大特性 (3) 多态

派生类转基类

并非通过继承关系的里氏替换原则,而是通过接口实现的,也就是所谓的鸭子方法

鸭子方法的多态非常简单

接口体现多态特征

  • 多态参数

    func greet(s SayHello) {	// 这里允许所有有该接口的变量传入 (无论是类还是自定义变量等)
        s.sayHello()
    }
    
  • 多态数组

    var arr [3]SayHello
    arr[0] = American{"rose"}
    arr[1] = Chinese{"张三"}
    

基类转派生类

这里可以通过Go的类型断言来实现,详见断言一章