摘要
在上一篇文章中,我们聊了聊Golang中的一些基础的语法,如变量的定义、条件语句、循环语句等等。他们和其他语言很相似,我们只需要看一看它们之间的区别,就差不多可以掌握了,所以作者称它们为“基础语法”。在这篇文章中,我们将聊一聊Golang的一些语言特性,这也是Golang和其他语言差别比较大的地方。除此之外,还有一部分内容是关于Golang的并发,这一部分将在下一篇文章中介绍。
1 结构体
在Java中,我们已经体会过了面向对象的方便之处。我们只需要将现实中的模型抽象出来,就成为了一个类,类里面定义了描述这个类的一些属性。
而在Golang中,则没有对象这一说法,因为Golang是一个面向过程的语言。但是,我们又知道面向对象在开发中的便捷性,所以我们在Golang中有了结构体这一类型。
结构体是复合类型,当需要定义类型,它由一系列属性组成,每个属性都有自己的类型和值的时候,就应该使用结构体,它把数据聚集在一起。
组成结构体类型的那些数据成为字段(fields)。每个字段都有一个类型和一个名字;在一个结构体中,字段名字必须是的。
我们可以近似的认为,一个结构体就是一个类,结构体内部的字段,就是类的属性。
注意,在结构体中也遵循用大小写来设置公有或私有的规则。如果这个结构体名字的个字母是大写,则可以被其他包访问,否则,只能在包内进行访问。而结构体内的字段也一样,也是遵循一样的大小写确定可用性的规则。
1.1 定义
对于结构体,他的定义方式如下:
type 结构体名 struct {
字段1 类型
字段2 类型
}
1.2 声明
对于结构体的声明和初始化,有以下几种形式:
使用var关键字
var s T
s.a = 1
s.b = 2
注意,在使用了var
关键字之后不需要初始化,这和其他的语言有些不同。Golang会自动分配内存空间,并将该内存空间设置为默认的值,我们只需要按需进行赋值即可。
使用new函数
type people struct {
name string
age int
}
func main() {
ming := new(people)
ming.name = "xiao ming"
ming.age = 18
}
使用字面量
type people struct {
name string
age int
}
func main() {
ming := &people{"xiao ming", 18}
}
1.3 区别
上面我们提到了几种结构体的声明的方法,但其实这几种是有些区别的。
先说结论,种使用var
声明的方式,返回的是该实例的结构类型,而第二第三种,返回的是一个指向这个结构类型的一个指针,是地址。
注意,这一部分作者可以保证是观点是正确的。但是作者的解释其实有些问题。这是因为作者能力有限,还没开始研究Golang的源码,所以不能很好的解释“返回的是实例的结构类型”这一句话。在作者的理解中,返回类型有两种,一种是具体的数值,一种是指向这个数值的指针。
所以,对于第二第三种返回指针的声明形式,在我们需要修改他的值的时候,其实应该使用的方式是:
(*ming).name = "xiao wang"
也就是说,对于指针类型的数值,应该要先用*
取值,然后再修改。
但是,在Golang中,可以省略这一步骤,直接使用ming.name = "xiao wang"
。尽管如此,我们应该知道这一行为的原因,分清楚自己所操作的对象究竟是什么类型,掌握这点对下面方法这一章节至关重要。
2 方法
在上一节的内容中,我们也提到了面向对象的优势,而Golang又是一种面向过程的语言。在上一章节中,提到了用结构体实现了对象这一概念。在这一章中,提到的是对象对应的方法。
在Go语言中有一个概念,它和方法有着同样的名字,并且大体上意思相同,Go的 方法是作用在接收器(receiver)上的一个函数,接收器是某种类型的变量,因此方法是一种特殊类型的函数。
说白了,方法就是函数,只不过是一种比较特殊的函数。
我们都知道,在Golang中,定义一个函数是这样的:
func 函数名(args) 返回类型
而在此基础上,在func
和函数名
之间,加上接受者的类型,就可以定义一个方法。
type Vertex struct {
X, Y float64
}
func (v Vertex) Abs() float64 {
return math.Sqrt(v.X*v.X + v.Y*v.Y)
}
func main() {
v := Vertex{3, 4}
fmt.Println(v.Abs())
}
可以看到,我们定义了一个Vertex
为接收者的方法。也就是说,这个方法,仅仅可以被Vertex
的结构体数值调用。
注意,接受者有两种类型,即指针接收者和非指针接受者。
我们来看下面的代码:
type Vertex struct {
X, Y float64
}
func (v Vertex) test1(){
v.X++;
v.Y++;
}
func (v *Vertex) test2(){
v.X++;
v.Y++;
}
在这里我们定义了两个方法,test1
和test2
,他们的区别就是方法名前面的接收者不同,一个是指针类型的,一个是值类型的。
并且,执行这两个方法,也需要定义不同的结构体类型。
v1 := Vertex{1, 1}
v2 := &Vertex{1, 1}
v1.test1()
v2.test2()
fmt.Println(v1)
fmt.Println(v2)
执行之后我们可以查看结果:
{1 1}
&{2 2}
也就是说,只有指针接收者类型的方法,才能修改这个接收器的成员值,非指针接收者,方法修改的只是这个传入的指针接收者的一个拷贝。
那么为什么会这样,我们同样拿代码说话:
type Vertex struct {
X, Y float64
}
func (v Vertex) test1(){
fmt.Printf("在方法中的v的地址为:%%p\n", &v)
v.X++;
v.Y++;
}
func main() {
v1 := Vertex{1, 1}
fmt.Printf("自己定义的v1内存地址为:%%p\n", &v1)
v1.test1()
}
在上述的代码中,我定义了一个非指针类型接收者的方法,然后打印方法外的v1和方法内的v的内存地址,结果如下:
自己定义的v1内存地址为:0xc00000a0e0
在方法中的v的地址为:0xc00000a100
我们可以看出,这两个结构体数值的内存地址是不一样的。也就是说,就算我们修改了方法内的数值,对方法外的原变量也不能起到任何的作用,因为我们修改的只是一个结构体数值的拷贝,没有真正的修改的他本来的值。
但是,如果使用的是指针接收者,他们的内存地址就是一样的了,下面看代码:
type Vertex struct {
X, Y float64
}
func (v *Vertex) test2(){
fmt.Printf("在方法中的v的地址为:%%p\n", v)
v.X++;
v.Y++;
}
func main() {
v1 := &Vertex{1, 1}
fmt.Printf("自己定义的v1内存地址为:%%p\n", v1)
v1.test2()
}
执行之后的结果为:
自己定义的v1内存地址为:0xc00000a0e0
在方法中的v的地址为:0xc00000a0e0
所以我们可以知道,使用指针接收器是可以直接拿到原数据所在的内存地址,也就是说可以直接修改原来的数值。这也和Java中的对象调用方法更加相似;而对于非指针,它是拷贝原来的数据。至于使用哪一种,需要按照实际的业务来处理。
但是,如果是一个大对象,如果也采用拷贝的方式,将会耗费大量的内存,降低效率。
还有一点需要补充说明:不管是指针接收者还是非指针接收者,他在接受一个对象的时候,会自动将这个对象转换为这个方法所需要的类型。也就是说,如果我现在有一个非指针类型的对象,去调用一个指针接收者的方法,那么这个对象将会自动被取地址然后再被调用。
换句话说,方法的调用类型不重要,重要的是方法是怎么定义的。
3 接口
在聊接口怎么用之前,我们先来聊聊接口的作用。
在作者看来,接口是一种规范,一种约定。举个例子:一个商品只要是符合某种种类的约定,遵循某种种类的规范,那么就可以认为这个商品是属于这个种类的,他会具有这个种类应有的一切功能。这样做的目的是为了把生产这个商品的生产者和使用这个商品的消费者分开。用编程里面的术语来讲,我们可以把实现和调用解耦。
下面举个鸭子模型的例子,来自于知乎,可以说特别的形象生动了。注意,在这里先不研究语法,语法的问题我们后面会提到,你只需要跟随作者的思路去思考:
首先我们定义一个规范,也就是说定义一个接口:
type Duck interface {
Quack() // 鸭子叫
DuckGo() // 鸭子走
}
这个接口是鸭子的行为,我们认为,作为一只鸭子,它需要会叫,会走。然后我们再定义一只鸡:
type Chicken struct {
}
假设这只鸡特别厉害,它也会像鸭子那样叫,也会像鸭子那样走路,那么我们定义一下这只鸡的行为:
func (c Chicken) Quack() {
fmt.Println("嘎嘎")
}
func (c Chicken) DuckGo() {
fmt.Println("大摇大摆的走")
}
注意,这里只是实现了 Duck 接口方法,并没有将鸡类型和鸭子接口显式绑定。这是一种非侵入式的设计。
然后我们让这只鸡,去叫,去像鸭子那样走路:
func main() {
c := Chicken{}
var d Duck
d = c
d.Quack()
d.DuckGo()
}
执行之后我们可以得到结果:
嘎嘎
大摇大摆的走
也就是说,这只鸡,他能做到鸭子能做的所有事情,那么我们可以认为,这只鸡,他就是一个鸭子。
这里牵涉到了一个概念,任何类型的数据,它只要实现了一个接口中方法集,那么他就属于这个接口类型。所以,当我们在实现一个接口的时候,需要实现这个接口下的所有方法,否则编译将不能通过。
理解了接口是什么之后,我们再来聊聊语法,首先是如何定义一个接口:
type 接口名 interface {
方法1(参数) 返回类型
方法2(参数) 返回类型
...
}
这一部分和结构体的定义很相似,但是里面的元素换成了函数,但是这个函数不需要func
关键字。这里和Java中的接口相似,不需要访问修饰符,只需要函数名参数返回类型。
定义完接口之后,我们需要定义方法去实现这些接口。注意,这里新定义方法的方法名,参数,返回类型,必须和接口中所定义的完全一致。
其次,这里的方法中的接收者,就是调用这个方法的对象类型。换句话说,这个方法想要被哪类对象执行,接收者就是谁。
还有重要的一点,如果要实现某个接口,必须要实现这个接口的全部方法。
在调用接口的时候,我们需要先声明这个接口类型的变量,如我们上面定义了一个Duck
接口,就应该声明一个Duck
类型的变量。
var d Duck
然后我们把实现了这个方法的接收器对象赋值给这个变量d
:
d := Chicken{}
随后,我们就可以用这个变量d
,是调用那些方法了。
写在后
首先,还是很感谢你能看到这里,谢谢你!(鞠躬
这是《Golang入门》系列的第三篇了,也差不多要讲完Golang的基本语法了。在这篇文章中是介绍了一些Golang的比较特殊的用法,希望能够对你有所帮助。
当然了,作者也只是个初学者,也才刚刚开始学习Golang。在学习的过程中,难免会有错误的理解,或者会有遗漏的地方。如果你发现了,请你留言指正我,谢谢!除此之外,如果有我讲的不清楚不明白的地方,也欢迎留言,我们一起交流学习。
在下一篇中,作者将介绍一下Golang的并发。我们下篇文章见。
再次感谢~
PS:如果有其他的问题,也可以在公众号找到作者。并且,所有文章时间会在公众号更新,欢迎来找作者玩~