方法
Last updated
Was this helpful?
Last updated
Was this helpful?
在 Go 语言中,结构体就像是类的一种简化形式,那么面向对象程序员可能会问:类的方法在哪里呢?在 Go 中有一个概念,它和方法有着同样的名字,并且大体上意思相同:Go 方法是作用在接收者 (receiver) 上的一个函数,接收者是某种类型的变量。因此方法是一种特殊类型的函数。
接收者类型可以是(几乎)任何类型,不仅仅是结构体类型:任何类型都可以有方法,甚至可以是函数类型,可以是 int
、bool
、string
或数组的别名类型。但是接收者不能是一个接口类型(参考),因为接口是一个抽象定义,但是方法却是具体实现;如果这样做会引发一个编译错误:invalid receiver type...
。
最后接收者不能是一个指针类型,但是它可以是任何其他允许类型的指针。
一个类型加上它的方法等价于面向对象中的一个类。一个重要的区别是:在 Go 中,类型的代码和绑定在它上面的方法的代码可以不放置在一起,它们可以存在在不同的源文件,唯一的要求是:它们必须是同一个包的。
类型 T
(或 *T
)上的所有方法的集合叫做类型 T
(或 *T
)的方法集 (method set)。
因为方法是函数,所以同样的,不允许方法重载,即对于一个类型只能有一个给定名称的方法。但是如果基于接收者类型,是有重载的:具有同样名字的方法可以在 2 个或多个不同的接收者类型上存在,比如在同一个包里这么做是允许的:
别名类型没有原始类型上已经定义过的方法。
定义方法的一般格式如下:
在方法名之前,func
关键字之后的括号中指定 receiver。
如果 recv
是 receiver 的实例,Method1
是它的方法名,那么方法调用遵循传统的 object.name
选择器符号:recv.Method1()
。
如果 recv
是一个指针,Go 会自动解引用。
如果方法不需要使用 recv
的值,可以用 _ 替换它,比如:
recv
就像是面向对象语言中的 this
或 self
,但是 Go 中并没有这两个关键字。随个人喜好,你可以使用 this
或 self
作为 receiver 的名字。下面是一个结构体上的简单方法的例子:
输出:
下面是非结构体类型上方法的例子:
定义结构体 employee
,它有一个 salary
字段,给这个结构体定义一个方法 giveRaise
来按照指定的百分比增加薪水。
下面这段代码有什么错?
类型和作用在它上面定义的方法必须在同一个包里定义,这就是为什么不能在 int
、float32(64)
或类似这些的类型上定义方法。试图在 int
类型上定义方法会得到一个编译错误:
比如想在 time.Time
上定义如下方法:
类型在其他的,或是非本地的包里定义,在它上面定义方法都会得到和上面同样的错误。
但是有一个间接的方式:可以先定义该类型(比如:int
或 float32(64)
)的别名类型,然后再为别名类型定义方法。或者像下面这样将它作为匿名类型嵌入在一个新的结构体中。当然方法只在这个别名类型上有效。
函数将变量作为参数:Function1(recv)
方法在变量上被调用:recv.Method1()
在接收者是指针时,方法可以改变接收者的值(或状态),这点函数也可以做到(当参数作为指针传递,即通过引用调用时,函数也可以改变参数的状态)。
不要忘记 Method1()
后边的括号 ()
,否则会引发编译器错误:method recv.Method1 is not an expression, must be called
接收者必须有一个显式的名字,这个名字必须在方法中被使用。
receiver_type
叫做 (接收者)基本类型,这个类型必须在和方法同样的包中被声明。
在 Go 中,(接收者)类型关联的方法不写在类型结构里面,就像类那样;耦合更加宽松;类型和方法之间的关联由接收者来建立。
方法没有和数据定义(结构体)混在一起:它们是正交的类型;表示(数据)和行为(方法)是独立的。
鉴于性能的原因,recv
最常见的是一个指向 receiver_type
的指针(因为我们不想要一个实例的拷贝,如果按值调用的话就会是这样),特别是在 receiver 类型是结构体时,就更是如此了。
如果想要方法改变接收者的数据,就在接收者的指针类型上定义该方法。否则,就在普通的值类型上定义方法。
下面的例子 pointer_value.go
作了说明:change()
接受一个指向 B
的指针,并改变它内部的成员;write()
通过拷贝接受 B
的值并只输出 B
的内容。注意 Go 为我们做了探测工作,我们自己并没有指出是否在指针上调用方法,Go 替我们做了这些事情。b1
是值而 b2
是指针,方法都支持运行了。
试着在 write()
中改变接收者 b
的值:将会看到它可以正常编译,但是开始的 b
没有被改变。
我们知道方法将指针作为接收者不是必须的,如下面的例子,我们只是需要 Point3
的值来做计算:
这样做稍微有点昂贵,因为 Point3
是作为值传递给方法的,因此传递的是它的拷贝,这在 Go 中是合法的。也可以在指向这个类型的指针上调用此方法(会自动解引用)。
假设 p3
定义为一个指针:p3 := &Point{ 3, 4, 5}
。
可以使用 p3.Abs()
来替代 (*p3).Abs()
。
因此 two2.AddThem
可以替代 (&two2).AddThem()
。
在值和指针上调用方法:
可以有连接到类型的方法,也可以有连接到类型指针的方法。
但是这没关系:对于类型 T
,如果在 \*T
上存在方法 Meth()
,并且 t
是这个类型的变量,那么 t.Meth()
会被自动转换为 (&t).Meth()
。
指针方法和值方法都可以在指针或非指针上被调用,如下面程序所示,类型 List
在值上有一个方法 Len()
,在指针上有一个方法 Append()
,但是可以看到两个方法都可以在两种类型的变量上被调用。
考虑 person2.go
中的 person
包:类型 Person
被明确的导出了,但是它的字段没有被导出。例如在 use_person2.go
中 p.firstName
就是错误的。该如何在另一个程序中修改或者只是读取一个 Person
的名字呢?
这可以通过面向对象语言一个众所周知的技术来完成:提供 getter()
和 setter()
方法。对于 setter()
方法使用 Set...
前缀,对于 getter()
方法只使用成员名。
并发访问对象
当一个匿名类型被内嵌在结构体中时,匿名类型的可见方法也同样被内嵌,这在效果上等同于外层类型 继承 了这些方法:将父类型放在子类型中来实现亚型。这个机制提供了一种简单的方式来模拟经典面向对象语言中的子类和继承相关的效果,也类似 Ruby 中的混入 (mixin)。
下面是一个示例(可以在练习 10.8 中进一步学习):假定有一个 Engine
接口类型,一个 Car
结构体类型,它包含一个 Engine
类型的匿名字段:
我们可以构建如下的代码:
内嵌将一个已存在类型的字段和方法注入到了另一个类型里:匿名字段上的方法“晋升”成为了外层类型的方法。当然类型可以有只作用于本身实例而不作用于内嵌“父”类型上的方法。
可以覆写方法(像字段一样):和内嵌类型方法具有同样名字的外层类型的方法会覆写内嵌类型对应的方法。
现在 fmt.Println(n.Abs())
会打印 500
。
结构体内嵌和自己在同一个包中的结构体时,可以彼此访问对方所有的字段和方法。
创建一个上面 Car
和 Engine
可运行的例子,并且给 Car
类型一个 wheelCount
字段和一个 numberOfWheels()
方法。
创建一个 Mercedes
类型,它内嵌 Car
,并新建 Mercedes
的一个实例,然后调用它的方法。
然后仅在 Mercedes
类型上创建方法 sayHiToMerkel()
并调用它。
主要有两种方法来实现在类型中嵌入功能:
A:聚合(或组合):包含一个所需功能类型的具名字段。
B:内嵌:内嵌(匿名地)所需功能类型,像前一节 10.6.5 所演示的那样。
为了使这些概念具体化,假设有一个 Customer
类型,我们想让它通过 Log
类型来包含日志功能,Log
类型只是简单地包含一个累积的消息(当然它可以是复杂的)。如果想让特定类型都具备日志功能,你可以实现一个这样的 Log
类型,然后将它作为特定类型的一个字段,并提供 Log()
,它返回这个日志的引用。
输出:
输出:
内嵌的类型不需要指针,Customer
也不需要 Add
方法,它使用 Log
的 Add
方法,Customer
有自己的 String
方法,并且在它里面调用了 Log
的 String
方法。
如果内嵌类型嵌入了其他类型,也是可以的,那些类型的方法可以直接在外层类型中使用。
因此一个好的策略是创建一些小的、可复用的类型作为一个工具箱,用于组成域类型。
多重继承指的是类型获得多个父类型行为的能力,它在传统的面向对象语言中通常是不被实现的(C++ 和 Python 例外)。因为在类继承层次中,多重继承会给编译器引入额外的复杂度。但是在 Go 语言中,通过在类型中嵌入所有必要的父类型,可以很简单的实现多重继承。
作为一个例子,假设有一个类型 CameraPhone
,通过它可以 Call()
,也可以 TakeAPicture()
,但是第一个方法属于类型 Phone
,第二个方法属于类型 Camera
。
输出:
定义一个结构体类型 Base
,它包含一个字段 id
,方法 Id()
返回 id
,方法 SetId()
修改 id
。结构体类型 Person
包含 Base
,及 FirstName
和 LastName
字段。结构体类型 Employee
包含一个 Person
和 salary
字段。
创建一个 employee
实例,然后显示它的 id
。
首先预测一下下面程序的结果,然后动手实验下:
在如 C++、Java、C# 和 Ruby 这样的面向对象语言中,方法在类的上下文中被定义和继承:在一个对象上调用方法时,运行时会检测类以及它的超类中是否有此方法的定义,如果没有会导致异常发生。
在 Go 语言中,这样的继承层次是完全没必要的:如果方法在此类型定义了,就可以调用它,和其他类型上是否存在这个方法没有关系。在这个意义上,Go 具有更大的灵活性。
下面的模式就很好的说明了这个问题:
Go 不需要一个显式的类定义,如同 Java、C++、C# 等那样,相反地,“类”是通过提供一组作用于一个共同类型的方法集来隐式定义的。类型可以是结构体或者任何用户自定义类型。
比如:我们想定义自己的 Integer
类型,并添加一些类似转换成字符串的方法,在 Go 中可以如下定义:
在 Java 或 C# 中,这个方法需要和类 Integer
的定义放在一起,在 Ruby 中可以直接在基本类型 int
上定义这个方法。
总结
在 Go 中,类型就是类(数据和关联的方法)。Go 不知道类似面向对象语言的类继承的概念。继承有两个好处:代码复用和多态。
在 Go 中,代码复用通过组合和委托实现,多态通过接口的使用来实现:有时这也叫 组件编程 (Component Programming)。
许多开发者说相比于类继承,Go 的接口提供了更强大、却更简单的多态行为。
备注
问题 10.1
我们在某个类型的变量上使用点号调用一个方法:variable.method()
,在使用 Go 以前,在哪儿碰到过面向对象的点号?
问题 10.2
a)假设定义: type Integer int
,完成 get()
方法的方法体: func (p Integer) get() int { ... }
。
b)定义: func f(i int) {}; var v Integer
,如何就 v
作为参数调用f?
c)假设 Integer
定义为 type Integer struct {n int}
,完成 get()
方法的方法体:func (p Integer) get() int { ... }
。
d)对于新定义的 Integer
,和 b)中同样的问题。
示例 10.10 :
示例 10.11 :
练习 10.6
练习 10.7
示例 10.12 :
示例 10.13 :
像例子 10.10 () 中接收者类型是 *TwoInts
的方法 AddThem()
,它能在类型 TwoInts
的值上被调用,这是自动间接发生的。
示例 10.14 :
示例 10.15 :
示例 10.16 :
对象的字段(属性)不应该由 2 个或 2 个以上的不同线程在同一时间去改变。如果在程序发生这种情况,为了安全并发访问,可以使用包 sync
(参考中的方法。在中我们会通过 goroutines 和 channels 探索另一种方式。
下面是 的完整例子,它展示了内嵌结构体上的方法可以直接在外层类型的实例上调用:
在示例 10.18 中添加:
因为一个结构体可以嵌入多个匿名类型,所以实际上我们可以有一个简单版本的多重继承,就像:type Child struct { Father; Mother}
。在中会进一步讨论这个问题。
练习 10.8
方式 A 可以通过如下方法实现(使用了中的 String()
功能):
示例 10.19 :
相对的方式 B 可能会像这样 ():
只要嵌入这两个类型就可以解决这个问题,如下所示 ():
练习 10.9 :
从 point.go
开始(的练习):使用方法来实现 Abs()
和 Scale()
函数,Point
作为方法的接收者类型。也为 Point3
和 Polar
实现 Abs()
方法。完成了 point.go
中同样的事情,只是这次通过方法。
练习 10.10 :
练习 10.11 :
在编程中一些基本操作会一遍又一遍的出现,比如打开 (Open)、关闭 (Close)、读 (Read)、写 (Write)、排序(Sort) 等等,并且它们都有一个大致的意思:打开 (Open)可以作用于一个文件、一个网络连接、一个数据库连接等等。具体的实现可能千差万别,但是基本的概念是一致的。在 Go 语言中,通过使用接口(参考),标准库广泛的应用了这些规则,在标准库中这些通用方法都有一致的名字,比如 Open()
、Read()
、Write()
等。想写规范的 Go 程序,就应该遵守这些约定,给方法合适的名字和签名,就像那些通用方法那样。这样做会使 Go 开发的软件更加具有一致性和可读性。比如:如果需要一个 convert-to-string()
方法,应该命名为 String()
,而不是 ToString()
(参考)。
如果真的需要更多面向对象的能力,看一下 包 (Go Object-Oriented Programming),它由 Scott Pakin 编写: 它给 Go 提供了 JavaScript 风格的对象(基于原型的对象),并且支持多重继承和类型独立分派,通过它可以实现你喜欢的其他编程语言里的一些结构。
上一节:
下一节: