Go:struct结构体和继承
文章目录
- 结构体概念
- 结构体定义
- 结构体生成
- 递归结构体
- 结构体转换
- 使用工厂方法创建结构体实例
- 如何强制使用工厂方法
- 带标签的结构体
- 匿名字段
- 内嵌结构体
- 命名冲突
- 方法
- 定义
- 函数和方法的区别
- 指针或值作为接受者
- 方法和未导出字段
- 多重继承
- 总结
- 格式化描述符
- 垃圾回收和 SetFinalizer
结构体概念
结构体定义
结构体基本定义的方式为
type identifier struct {field1 type1field2 type2...
}
结构体生成
对于结构体来说,它的生成逻辑如下,一般来说要通过使用new来生成,例如
t := new(T)
具体的使用看下面的demo代码
func test1() {s1 := new(test1Struct)s1.a = 10s1.b = 10.0s1.c = "hello go"fmt.Println(s1)var s2 test1Structs2.a = 20s2.b = 20.0s2.c = "hello goo"fmt.Println(s2)s3 := test1Struct{10, 20.0, "hello go"}fmt.Println(s3)s4 := test1Struct{a: 20, c: "hello oo"}fmt.Println(s4)s5 := &test1Struct{a: 10, b: 20}fmt.Println(s5)
}
类型 struct1 在定义它的包 pack1 中必须是唯一的,它的完全类型名是:pack1.struct1
递归结构体
结构体类型可以通过引用自身来定义,这点在链表和二叉树中用的比较普遍,这里就不多赘述了,比如可能像是这种
tp1
type node struct {value intnext *node
}
结构体转换
Go当中的类型转换有比较严格的规则,比如给两个结构体具有相同的底层类型的时候,可以进行转换,但是要注意非法赋值和转换引起的编译错误
func test2() {a := test2S1{10.0}b := test2S2{11.1}fmt.Println(a, b)var c test2S2//c = a 无法将 'a' (类型 test2S1) 用作类型 test2S2c = test2S2(a)fmt.Println(c)
}
使用工厂方法创建结构体实例
Go 语言不支持面向对象编程语言中那样的构造子方法,但是可以很容易的在 Go 中实现 “构造子工厂”方法。为了方便通常会为类型定义一个工厂,按惯例,工厂的名字以 new… 或 New… 开头。假设定义了如下的 File 结构体类型
type test3File struct {fd int // 文件描述符name string // 文件名
}
下面是这个结构体类型对应的工厂方法
func test3NewFile(fd int, name string) *test3File {if fd < 0 {return nil}return &test3File{fd, name}
}
于是就可以这样进行调用
func test3() {f := test3NewFile(10, "./hello.txt")fmt.Println(f)
}
这里对比一下C++和Go
在这段代码中,如果从 C++的角度来看确实存在返回指向已销毁对象的风险。
在 C++中,当一个函数返回一个指向局部对象的指针时,一旦函数执行完毕,局部对象的生命周期结束,其占用的内存可能会被回收或者被其他数据覆盖。在这个例子中,test3File{fd, name}
是在函数test3NewFile
内部创建的一个局部对象,当函数返回时,这个局部对象会被销毁,而返回的指针将指向一个无效的内存区域。
为了避免这种情况,可以考虑以下几种方法:
- 使用动态分配内存的方式(如
new
操作符)来创建对象,并在合适的时候使用delete
释放内存。但这种方式需要手动管理内存,容易出现内存泄漏和悬挂指针等问题。 - 通过传递一个已存在的对象或者对象的引用作为参数,在函数内部对其进行修改并返回。这样可以避免创建局部对象并返回指向它的指针。
- 使用智能指针(如
std::unique_ptr
或std::shared_ptr
)来管理对象的生命周期,确保在不需要对象时自动释放内存。
总之,在 C++中需要特别注意返回指向局部对象的指针的情况,以避免出现未定义的行为和潜在的错误。
那为什么在Go当中可以这样进行使用呢?
在 Go 语言中可以这样做是因为 Go 有不同的内存管理机制和设计理念。
Go 语言中的函数返回局部变量的地址是安全的,主要有以下几个原因:
一、逃逸分析
Go 编译器会进行逃逸分析。在这个例子中,虽然看起来test3File{fd, name}
是一个局部变量,但如果它的地址被返回并在函数外部被使用,编译器可能会决定在堆上分配这个变量,而不是在栈上。这样就确保了变量的生命周期不会随着函数的返回而结束。
二、垃圾回收机制
Go 语言有自动的垃圾回收机制。即使变量在堆上分配,垃圾回收器会跟踪对象的引用情况,并在合适的时候回收不再被引用的对象所占用的内存。这意味着开发者不需要像在 C++中那样手动管理内存,大大降低了内存错误的风险。
总的来说,Go 语言的设计使得开发者在很多情况下可以更自然地编写代码,而不必像在 C++中那样时刻担心内存管理的问题。但这也并不意味着可以随意返回局部变量的地址,了解 Go 的内存管理机制和编译器的行为仍然是很重要的,以确保代码的正确性和性能。
由此其实可以看出,在这方面,Go比C++要灵活很多
如何强制使用工厂方法
可以借助包的可见性来完成,将结构体类型设置为私有,那么在其他包想要直接new进行创建的时候就会失败,必须要通过工厂模式来返回一个对应的数据成员才可以
比如,现在在struct包当中定义了这样的结构体
type test3File struct {fd int // 文件描述符name string // 文件名
}
那么在另外一个包,如果我想直接new来进行创建这个结构体
f := new(structpackage.test3File)
这样做是不被允许的,会报错为:无法在当前软件包中使用未导出的 类型 ‘test3File’
那实际应该这样进行使用,在package中定义一个这样的函数
func main() {f := structpackage.Test3NewFile(10, "./test.txt")fmt.Println(f)structpackage.Test()
}
带标签的结构体
结构体中的字段除了有名字和类型外,还可以有一个可选的标签 (tag):它是一个附属于字段的字符串,可以是文档或其他的重要标记。标签的内容不可以在一般的编程中使用,只有包 reflect 能获取它
具体代码如下
type test4Struct struct {str string "this is string"number int "this is number"
}func test4RefTag(t4s test4Struct, idx int) {structType := reflect.TypeOf(t4s)ixFiled := structType.Field(idx)fmt.Println(ixFiled.Tag)
}func test4() {tt := test4Struct{str: "hello go",number: 0,}for i := 0; i < 2; i++ {test4RefTag(tt, i)}
}
匿名字段
结构体可以包含一个或多个 匿名(或内嵌)字段,即这些字段没有显式的名字,只有字段的类型是必须的,此时类型就是字段的名字。匿名字段本身可以是一个结构体类型,即 结构体可以包含内嵌结构体
可以粗略地将这个和面向对象语言中的继承概念相比较,随后将会看到它被用来模拟类似继承的行为。Go 语言中的继承是通过内嵌或组合来实现的,所以可以说,在 Go 语言中,相比较于继承,组合更受青睐
比如下面的程序
type inner struct {inner1 intinner2 string
}type outer struct {flag boolnumber intintinner
}func test5() {//o1 := new(outer)var o1 outero1.flag = falseo1.number = 10o1.int = 20o1.inner1 = 10o1.inner2 = "hello go"fmt.Println(o1)
}
通过类型 outer.int 的名字来获取存储在匿名字段中的数据,于是可以得出一个结论:在一个结构体中对于每一种数据类型只能有一个匿名字段
内嵌结构体
结构体的概念并不陌生,而在上面的例子中可以看出,Go实际上更偏向于用这样的方式来实现一个类似于继承的方式,使得可以从另外一个或一些类型继承部分或者全部的实现
命名冲突
如果在两个字段有相同的名字,此时会如何进行处理?
- 外层名字会覆盖内层名字(但是两者的内存空间都保留),这提供了一种重载字段或方法的方式;
- 如果相同的名字在同一级别出现了两次,如果这个名字被程序使用了,将会引发一个错误(不使用没关系)。没有办法来解决这种问题引起的二义性,必须由程序员自己修正
方法
定义
前面的内容是关于结构体的定义部分,而关于类的成员函数部分并没有提及
在 Go 中有一个概念,它和方法有着同样的名字,并且大体上意思相同:Go 方法是作用在接收者 (receiver) 上的一个函数,接收者是某种类型的变量。因此方法是一种特殊类型的函数
对于这个概念的理解,可以理解为,实际上任何类型都可以有方法,int,string等都可以有
一个类型,加上它的方法,实际上就是相当于之前面向对象中的类的概念,但是区别是,在go当中,类型的代码和绑定在它上面的方法可以不放在一起,他们可以在不同的文件,但是必须是一个包
函数和方法的区别
再看看函数和方法的区别
- 函数将变量作为参数来进行传递
- 方法是在变量上进行调用
那具体怎么理解呢?实际上,当接受者是一个指针的时候,对于这个内容的操作是可以改变接受者的值,而这一点在函数上实际上也能做到
接收者必须有一个显式的名字,这个名字必须在方法中被使用。
receiver_type 叫做 (接收者)基本类型,这个类型必须在和方法同样的包中被声明。
在 Go 中,(接收者)类型关联的方法不写在类型结构里面,就像类那样;耦合更加宽松;类型和方法之间的关联由接收者来建立。
方法没有和数据定义(结构体)混在一起:它们是正交的类型;表示(数据)和行为(方法)是独立的
指针或值作为接受者
出于性能考虑的原因,recv最常见的是一个指向receiver_type的指针,这样可以避免实例的拷贝操作,特别是这个类型是一个结构体类型的时候
如果在这个方法上想要改变接收者的数据,那么就直接在这个接收者的指针类型上定义该方法,否则就在值类型上定义即可,比如下面的例子
func test6() {ts1 := test6S{thing: 10}ts2 := test6S{thing: 10}ts1.change1()fmt.Println(ts1)ts2.change2()fmt.Println(ts2)
}
只有作用在指针接收器上,才能正确的修改内部的值,否则是不能正确修改的
方法和未导出字段
看下面的场景,假设我在package包的一个文件中定义了下面的结构体:
type Test7S struct {firstname stringlastname string
}
此时我们说,这个结构体已经被导出了,那么在另外的包中就可以直接使用这个结构体,比如这样进行使用
func test1() {var ts structpackage.Test7Sfmt.Println(ts)
}
但如果说,想要进行修改字段呢?比如这样:
ts.firstname = "hello"ts.lastname = "go"
此时就会提示错误:无法在当前软件包中使用未导出的 字段 ‘lastname’
这是因为,这个字段实际上并没有被导出,此时会使用面向对象语言中的一个经典操作:
get()和set()
具体展示如下:
type Test7S struct {firstname stringlastname string
}func (ts *Test7S) GetFirstname() string {return ts.firstname
}func (ts *Test7S) GetLastname() string {return ts.lastname
}func (ts *Test7S) SetFirstname(fn string) {ts.firstname = fn
}func (ts *Test7S) SetLastname(ln string) {ts.lastname = ln
}
那么在实际的使用中,就可以这样进行使用
func test1() {var ts structpackage.Test7Sts.SetFirstname("hello")ts.SetLastname("go")fmt.Println(ts.GetFirstname(), ts.GetLastname())
}
并发访问对象
对象的字段(属性)不应该由 2 个或 2 个以上的不同线程在同一时间去改变。如果在程序发生这种情况,为了安全并发访问,可以使用包 sync
多重继承
多重继承指的是类型获得多个父类型行为的能力,它在传统的面向对象语言中通常是不被实现的(C++ 和 Python 例外)。因为在类继承层次中,多重继承会给编译器引入额外的复杂度。但是在 Go 语言中,通过在类型中嵌入所有必要的父类型,可以很简单的实现多重继承
以如下的例子为例:假设一个CameraPhone,它可以call,也可以takePicture,那么就可以从两个类中来继承这些方法:
type Camera struct{}func (c *Camera) TakeAPicture() string {return "Click"
}type Phone struct{}func (p *Phone) Call() string {return "Ring Ring"
}type CameraPhone struct {CameraPhone
}func test7() {cp := new(CameraPhone)fmt.Println("Our new CameraPhone exhibits multiple behaviors...")fmt.Println("It exhibits behavior of a Camera: ", cp.TakeAPicture())fmt.Println("It works like a Phone too: ", cp.Call())
}
总结
总结
在 Go 中,类型就是类(数据和关联的方法)。Go 不知道类似面向对象语言的类继承的概念。继承有两个好处:代码复用和多态。
在 Go 中,代码复用通过组合和委托实现,多态通过接口的使用来实现:有时这也叫 组件编程 (Component Programming)。
许多开发者说相比于类继承,Go 的接口提供了更强大、却更简单的多态行为
格式化描述符
当定义了一个有很多方法的类型时,String() 方法来定制类型的字符串形式的输出,换句话说:一种可阅读性和打印性的输出。如果类型定义了 String() 方法,它会被用在 fmt.Printf() 中生成默认的输出:等同于使用格式化描述符 %v 产生的输出。还有 fmt.Print() 和 fmt.Println() 也会自动使用 String() 方法
比如,可以这样:
type test8S struct {a intb int
}func (ts test8S) String() string {return "my format hhh: " + strconv.Itoa(ts.a) + " and " + strconv.Itoa(ts.b)
}func test8() {ts1 := test8S{10, 20}ts2 := &test8S{10, 20}fmt.Println(ts1)fmt.Println(ts2)
}
垃圾回收和 SetFinalizer
Go 开发者不需要写代码来释放程序中不再使用的变量和结构占用的内存,在 Go 运行时中有一个独立的进程,即垃圾收集器 (GC),会处理这些事情,它搜索不再使用的变量然后释放它们的内存。可以通过 runtime 包访问 GC 进程