vlambda博客
学习文章列表

浅谈Go语言 - 函数与结构体

1. 写在前面

  本章节我们介绍 Go 语言的模块化编程,包括几个重要的数据类型以及一些模块化编程的技巧。

2. 函数的使用

  在 Go 语言中,函数是一等的(first-class)公民,函数类型也是一等的数据类型。这意味着函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等,就像切片和字典的值那样。

  函数值可以由此成为能够被随意传播的独立逻辑组件(或者说功能模块)。对于函数类型来说,它是一种对一组输入、输出进行模板化的重要工具,它比接口类型更加轻巧、灵活,它的值也变成了可被热替换的逻辑组件。

(1) 函数签名

  我们先看一段代码

package main

import "fmt"

type Printer func(contents string) (n int, err error)

func printToStd(contents string) (bytesNum int, err error)
 {
  return fmt.Println(contents)
}

func main() {
  var p Printer
  p = printToStd
  p("something")
}

  代码中声明了一个函数类型Printer,声明的右边是关键字func,在func右边的就是这个函数类型的参数列表和结果列表。其中,参数列表必须由圆括号包裹,而只要结果列表中只有一个结果声明,并且没有为它命名,我们就可以省略掉外围的圆括号。

  书写函数签名的方式与函数声明基本一致,只是函数名称和func互换了位置。

  只要两个函数的参数列表和结果列表中的元素顺序及其类型是一致的,它们就是一样的函数,或者说是实现了同一个函数类型的函数。

  函数Printer的签名与printToStd的是一致的,因此printToStdPrinter的一个实现,即使它们的名称以及有的结果名称是不同的。

  现在再回去看代码里面main函数的定义和执行,是不是更加的清晰了。

(2) 高阶函数

  高阶函数也是函数式编程中的重要概念和特征,高阶函数满足以下的两个条件之一。

  1. 接受其他的函数作为参数传入

  2. 把其他的函数作为结果返回

函数作为参数传入

  我们先看代码:

package main

import (
    "errors"
    "fmt"
)

type operate func(x, y int) int

func calculate(x int, y int, op operate) (int, error)
 {
    if op == nil {
        return 0, errors.New("invalid operation")
    }
    return op(x, y), nil
}

func main() {
    add := func(x, y int) int {
        return x + y
    }
    sub := func(x, y int) int {
        return x - y
    }

    x, y := 1012
    resultAdd, _ := calculate(x, y, add)
    fmt.Printf("calculate add(%v, %v) --> %v\n", x, y, resultAdd)

    a, b := 1210
    resultSub, _ := calculate(a, b, sub)
    fmt.Printf("calculate sub(%v, %v) --> %v\n", a, b, resultSub)
}

// go run result:
// calculate add(10, 12) --> 22
// calculate sub(12, 10) --> 2

  以上代码中calculate函数就是一个高阶函数,calculate函数中先用卫述语句检查一下参数,如果operate类型的参数opnil,那么就直接返回0和一个代表了具体错误的error类型值。

卫述语句是指被用来检查关键的先决条件的合法性,并在检查未通过的情况下立即终止当前代码块执行的语句。在 Go 语言中,if 语句常被作为卫述语句。

  我们在main函数中传入一个operate类型的函数值,这个函数值应该怎么写?只要它的签名与operate类型的签名一致,并且实现得当就可以了,所以在add里面实现一个加法函数。

函数作为结果返回

  下面我们来看一段使用函数作为结果返回的代码:

package main

import (
    "errors"
    "fmt"
)

type operate func(x, y int) int

type calculateFunc func(x int, y int) (int, error)

func genCalculator(op operate) calculateFunc
 {
    return func(x int, y int) (int, error) {
        if op == nil {
            return 0, errors.New("invalid operation")
        }
        return op(x, y), nil
    }
}

func main() {
    opMultiply := func(x, y int) int {
        return x * y
    }

    x, y := 23
    multiply := genCalculator(opMultiply)
    result, _ := multiply(x, y)
    fmt.Printf("calculate multiply(%v, %v) --> %v\n", x, y, result)
}

// go run result:
// calculate multiply(2, 3) --> 6

  genCalculator函数的唯一结果的类型就是calculateFunc

闭包

  闭包专业术语叫自由变量,在一个函数中存在对外来标识符的引用,外来标识符既不代表当前函数的任何参数或结果,也不是函数内部声明的,它是直接从外边拿过来的。

  闭包函数就是因为引用了自由变量,而呈现出了一种“不确定”的状态,也叫“开放”状态。它的内部逻辑并不是完整的,有一部分逻辑需要这个自由变量参与完成,而后者到底代表了什么在闭包函数被定义的时候却是未知的。

  代码中的genCalculator函数内部,就实现了一个闭包

func genCalculator(op operate) calculateFunc {
    return func(x int, y int) (int, error) {
        if op == nil {
            return 0, errors.New("invalid operation")
        }
        return op(x, y), nil
    }
}

  genCalculator函数只做了一件事,那就是定义一个匿名的、calculateFunc类型的函数并把它作为结果值返回。

  这个匿名的函数就是一个闭包函数。它里面使用的变量op既不代表它的任何参数或结果也不是它自己声明的,而是定义它的genCalculator函数的参数,所以是一个自由变量。这个自由变量究竟代表了什么,这一点并不是在定义这个闭包函数的时候确定的,而是在genCalculator函数被调用的时候确定的。

  Go 语言编译器读到if op == nil {这里时会试图去寻找op所代表的东西,它会发现op代表的是genCalculator函数的参数,然后,它会把这两者联系起来。这时可以说,自由变量op被“捕获”了。当程序运行到这里的时候,op就是那个参数值了。如此一来,这个闭包函数的状态就由“不确定”变为了“确定”,或者说转到了“闭合”状态,至此也就真正地形成了一个闭包。

  实现闭包的意义是在动态生成那部分程序的逻辑,这与 GoF 设计模式中的“模板方法”模式有着异曲同工之妙。

传入函数的参数值情况

  我们继续看一段代码:

package main

import "fmt"

func main() {
  array1 := [3]string{"a""b""c"}
  fmt.Printf("The array: %v\n", array1)
  array2 := modifyArray(array1)
  fmt.Printf("The modified array: %v\n", array2)
  fmt.Printf("The original array: %v\n", array1)
}

func modifyArray(a [3]string) [3]string {
  a[1] = "x"
  return a
}

  输出结果为:

The array: [a b c]
The modified array: [a x c]
The original array: [a b c]

  根据以上代码的执行结果,能看出传给函数的参数值都会被复制,函数在其内部使用的并不是参数值的原值,而是它的副本。

  由于数组是值类型,所以每一次复制都会拷贝它,以及它的所有元素值。在modify函数中修改的只是原数组的副本而已,并不会对原数组造成任何影响。

  对于引用类型,比如:切片、字典、通道,像上面那样复制它们的值,只会拷贝它们本身而已,并不会拷贝它们引用的底层数据。以切片值为例,如此复制的时候,只是拷贝了它指向底层数组中某一个元素的指针,以及它的长度值和容量值,而它的底层数组并不会被拷贝。

  就算我们传入函数的是一个值类型的参数值,但如果这个参数值中的某个元素是引用类型的,那就需要注意了。

package main

import "fmt"

func main() {
    complexArray1 := [3][]string{
        []string{"d""e""f"},
        []string{"g""h""i"},
        []string{"j""k""l"},
    }
    fmt.Printf("The complexArray1: %v\n", complexArray1)
    complexArray2 := modifyArray(complexArray1)
    fmt.Printf("The modified complexArray2: %v\n", complexArray2)
    fmt.Printf("The original complexArray1: %v\n", complexArray1)

}

func modifyArray(a [3][]string) [3][]string {
    a[1][1] = "x"
    return a
}

  以上代码运行结果:

The complexArray1: [[d e f] [g h i] [j k l]]
The modified complexArray2: [[d e f] [g x i] [j k l]]
The original complexArray1: [[d e f] [g x i] [j k l]]

3. 结构体

(1) 定义

  通过代码直接看结构体的定义

// Building 代表建筑的基本信息
type Building struct {
    area    float32 // 面积
    roomnum int     // 房间数
}

func (bd Building) String() string {
    return fmt.Sprintf("area:%f, roomnum:%d", bd.area, bd.roomnum)
}

  上面代码中的String方法的功能是提供当前值的字符串表示形式。

  结构体的使用:

func main() {
    building := Building{area: 100, roomnum: 5}
    fmt.Printf("The building: {%s}\n", building)
}

// The building: {area:100.000000, roomnum:5}

  知识点:在 Go 语言中,我们可以通过为一个类型编写名为String的方法,来自定义该类型的字符串表示形式。这个String方法不需要任何参数声明,但需要有一个string类型的结果声明。方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。

(2) 嵌入字段

  继续看如下代码:

type House struct {
    name     string // 名称
    Building        // 建筑的基本信息
}

func (hs House) String() string {
    return fmt.Sprintf("name:%s, %s", hs.name, hs.Building.String())
}

  能够发现另一个字段声明中只有Building,字段声明Building代表了House类型的一个嵌入字段。Go 语言规范规定,如果一个字段的声明中只有字段的类型名而没有字段的名称,那么它就是一个嵌入字段,也可以被称为匿名字段。我们可以通过此类型变量的名称后跟“.”,再后跟嵌入字段类型的方式引用到该字段。也就是说,嵌入字段的类型既是类型也是名称。

  下面我们使用house来定义

func main() {
    building := Building{area: 100, roomnum: 5}
    fmt.Printf("The building: {%s}\n", building)

    house := House{
        name:     "apartment",
        Building: building,
    }
    fmt.Printf("The house: {%s}\n", house)
}

  Go 语言中没有继承的概念,只有类型间的组合,具体的原因可见Go语言官网:

  https://golang.org/doc/faq#inheritance

(3) 值方法和指针方法

  上面看到的函数都是值方法,下面我们看一段指针方法:

func (hs *House) SetName(name string) {
    hs.name = name
}

  调用指针方法后,输出结果:

func main() {
    building := Building{area: 100, roomnum: 5}
    fmt.Printf("The building: {%s}\n", building)

    house := House{
        name:     "apartment",
        Building: building,
    }
    fmt.Printf("The house: {%s}\n", house)

    house.SetName("residence")
    fmt.Printf("The house: {%s}\n", house)
}

// The building: {area:100.000000, roomnum:5}
// The house: {name:apartment, area:100.000000, roomnum:5}
// The house: {name:residence, area:100.000000, roomnum:5}

  根据以上事例,值方法和指针方法的区别已经比较明显了。

  值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,就会修改原值。

  临近春节,祝大家在新的一年,能够用技术给自己创造越来越多价值,当然不止是金钱上的。

参考文献:

  • 极客时间:Go语言核心36讲 by 郝林

  • https://golang.org/doc/faq#inheritance