Golang入门(3):一天学完GO的进阶语法

GoatSucker 2020-04-10

摘要

在前一篇文章中,我们聊了聊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++;   
}

在这里我们定义了两个方法,test1test2,他们唯一的区别就是方法名前面的接收者不同,一个是指针类型的,一个是值类型的。

并且,执行这两个方法,也需要定义不同的结构体类型。

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

定义完接口之后,需要实现这些接口。我们需要定义方法去实现这些接口。注意,这里新定义的方法名参数返回类型,必须和接口中所定义的完全一致

其次,这里的方法中的接受者,就是你所需的调用这个方法的对象。

还有最重要的一点,实现某个接口,必须要实现这个接口的全部方法。

在调用接口的时候,我们需要先声明这个接口类型的变量,如我们上面定义了一个Duck接口,就应该声明一个Duck类型的变量。

var d Duck

然后我们把实现了这个方法的接收器对象赋值给这个变量d

d := Chicken{}

随后,我们就可以用这个变量d,是调用那些方法了。

写在最后

首先,还是很感谢你能看到这里,谢谢你!

这是《Golang入门》系列的第三篇了,也差不多要讲完Golang的基本语法了。在这篇文章中是介绍了一些Golang的比较特殊的用法,希望能够对你有所帮助。

当然了,作者也只是个初学者,也才刚刚开始学习Golang这门发展势头特别快的语言,在学习的过程中,难免会有错误的理解,或者会有遗漏的地方。如果你发现了,请你留言指正我,谢谢!除此之外,如果有作者讲的不清楚不明白的地方,也欢迎留言,我们一起交流学习。

在下一篇中,作者将介绍一下Golang的并发。我们下篇文章见。

再次感谢~

相关推荐