23 函数作用域与闭包

作用域

作用域并非函数的子知识点,之所以在此处介绍,是因为在学习完函数基本使用后,我们才可以成体系介绍它

一 作用域介绍

  • 1、什么是作用域?

    域指的是范围,作用域名指的是名字(变量名、函数名等)可以被有效访问的范围

  • 2、为何要学习作用域?

    声明语句会将程序中的实体和一个名字关联,比如函数名、变量名等名字,而声明的意义在于以后引用,若想引用声明实体就需要用到名字,但我们在程序中声明了那么多名字,当引用一个名字时应该去哪里找?名字若出现冲突了,访问的优先级是什么?这就是我们学习作用域的意义。

  • 3、词法域

    作用域分为静态作用域(又称之为词法域)与动态作用域,go与python都是静态作用域,详解如下

    编译器的第一个工作阶段叫做词法化,也叫单词化,所以说,词法作用域是在词法阶段生成的作用域,是一个编译时的概念。与词法域相对应的是动态作用域,二者对比如下
    1、词法作用域:变量的作用域是在定义时决定而不是执行时决定,也就是说词法作用域取决于源码,通过静态分析就能确定,因此词法作用域也叫做静态作用域。而动态作用域是在运行时确定的。
    3、词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用,即动态作用域链是基于运行时的调用栈的

    总结:词法域是声明语句的域,而非引用语句的域,即声明语句的地方决定了作用域。

    详细地说就是:

    层层嵌套的关系是以定义/声明阶段为准生成的,定义/声明位置决定了作用域的嵌套关系而与引用位置无关,即无论在何处引用/查找名字,都需要老老实实地回到该名字当初定义的地方去找作用域关系,然后以此为准,由内向外一层层查找

    所以,搞清楚作用域的关键在于了解声明语句都可以声明在哪些地方,请看下一小节

二 代码块与作用域

毫无疑问,声明语句肯定是处于某一级别的代码块里,那什么是代码块呢,又是如何区分层级的呢?

代码块指的是一组的代码(声明语句肯定也是代码)集合

补充说明:
1、同一组代码属于一个级别,同级代码会按照自上而下的顺序依次执行。
2、语法块/语句块指的就是一组声明语句的集合,与代码块其实是一个东西,不必纠结此概念

go语言中有用{}的显式声明的代码块,当然也有没有用{}包含的隐式代码块,主要有以下几种,

  • 1、全局语法块

    我们可以把整个源代码看成一个大的代码块,包含它外面的,也就是最外层的,称之为全局语法块,可以在整个程序中直接使用,例如内置的类型、内置函数、内置的常量等。

  • 2、包级语法块

    函数外部只能有声明语句,这一系列的声明语句构成包级语法块,可以在同一个包的任何源文件中访问

    注意:首先导入语句也是声明语句,只能在包级声明,然后对于导入的包,例如在当前文件中导入的fmt包,则是对应源文件级的作用域,只能在当前的文件中访问导入的fmt包,即便是当前包的其它源文件也无法访问在当前源文件导入的包,若也想使用包fmt,需要重新导入才行

  • 3、函数内的语法块

    • 3.1 用{}包含的函数体代码,是函数内最外一层的代码块

    • 3.2 函数内的语句如每个for、if、switch语句声明部分

    • 3.3 每个for、if、switch语句{}包含的子代码

    • 3.4 单纯用{}声明的块

    一个例子说明

    package main
    
    import "fmt"
    
    func main() {
    // 1、此处是函数最外层一层代码块=>显式的
    var i int = 666
    
    for i := 0; i < 3; i++ { // 2、本行代码是for语句声明部分的代码块=>隐式的
        // 3、此处是for循环的子代码块=>显式的
        fmt.Println(i)
    }
    {
          // 4、此时是单纯用{}声明的一级代码块=>显式的
        var i int = 777
        fmt.Println("===>", i)
    }
    fmt.Println("--->", i)
    }

总结:代码块之间有嵌套关系,也有并列关系,这些关系到了名字的访问顺序,即作用域。

再来看你一个例子强化一下理解

// 可以依次注释var声明语句查看效果
import "fmt"

var len int = 666

func main() {
    var len int = 777
    {
        var len int = 888
        {
            var len int = 999
            fmt.Println(len) // 999
        }
        fmt.Println(len) // 888
    }
    fmt.Println(len) // 777
}

三 关于作用域强调6点

3.1 词法域的核心

再次强调静态作用域/词法域的核心是:嵌套关系是以定义/声明时为准的

package main

import "fmt"

var x int = 111

func foo() {
    fmt.Println(x)
}

func main() {
    var x int = 222
    foo()          // 调用函数,打印结果为111,而不是222
    fmt.Println(x) // 打印结果为222
}

3.2 关于声明同名变量的问题

  • 1、同一个作用域名,名字不能重复,这点写惯了python的人很容易犯错

    import "fmt"
    
    func main() {
    var x int = 111
    var x int = 222  // 错误,同一个代码块中不能重复声明
    fmt.Println(x)  
    }
  • 2、在不同的作用域确实可以声明相同的名字,但是go语言不建议这么做 (2.1中的例子只是为演示方便),因为如果名字在内部和外部的块分别声明过,则内部块的声明首先被找到,此时,内部声明屏蔽了外部同名的声明,让外部的声明的名字无法被访问,如果滥用不同词法域可重名的特性的话,可能导致程序很难阅读。

    package main
    
    import "fmt"
    
    func main() {
    var fmt int = 1111
    fmt.Println("hello") // 无法执行打印功能,因为内部的fmt屏蔽了外部
    }

3.3 函数相关

  • 1 函数最外层是什么?

    在函数体中,函数的形参和有名返回值作为函数最外层的局部变量,被存储在相同的词法块中。

    package main
    
    import "fmt"
    
    func foo(x, y int) (z int) {
    // 最外层相当于声明了
    // var x int = 111   // 接收实参111
    // var y int = 222   // 接收实参222
    // var z int         // 初始化零值
    fmt.Println(x, y, z) // 输出:111 222 0
    z = x + y            // 赋值操作,z被改为333
    return z
    }
    
    func main() {
    res:=foo(111, 222)
    fmt.Println(res)    // 333
    }
  • 2 函数体修改外部传入的值

    实参通过值的方式传递,因此函数的形参是实参的拷贝。在函数内对值的修改不会影响原值。但是,如果实参包括引用类型,如指针,slice(切片)、map、function、channel等类型,在函数内的修改会影响原值

3.4 包级作用域与声明顺序

在包级别,声明的顺序并不会影响作用域范围,因此一个先声明的可以引用它自身或者是引用后面的一个声明,这可以为我们带来一些额外的效果

  • 1、有名函数无需先定义后调用
    有名函数必须声明在包级,包级声明在使用时无所谓顺序,所以有名函数使用,跟定义位置无关,只要定义了,在任何位置都能用,无需先定义后使用

    import "fmt"
    
    func main() {
    sayHi()
    }
    
    // 后定义的函数
    func sayHi() {
    fmt.Println("hello")
    }
  • 2、包级声明的变量,在后面定义,也可以引用

    import "fmt"
    
    func main() {
    fmt.Println(name)
    }
    
    // 后定义的变量
    var name = "hello"
    
    // 但是如果一个变量或常量递归引用了自身,则会产生编译错误。
    // var x = x  // 编译错误

    强调:如果没有在包级,而是在函数内部,变量肯定是要先定义再使用

3.5 注意短变量声明语句的作用范围

简短声明与赋值符号很像对不对,很容易搞混,所以要特别注意短变量声明语句的作用域范围。

例1:局部声明的重名变量会覆盖包级的

import "fmt"

var x int

func main() {
    x, y := 1, 2  // 此处是声明语句,在局部声明了一个变量x,覆盖了包
    fmt.Println(x, y)
} 

例2:若想修改包级变量值,需要这么做

package main

import "fmt"

var x int

func main() {
    var y int
    x, y = 1, 2 // 此处是赋值语句
    fmt.Println(x, y)
}

上述两个例子看似简单,却是在教你解决一个非常隐晦的bug

考虑下面的程序:

// 获取当前的工作目录然后保存到一个包级的变量中。这可以本来可以在包级直接调用os.Getwd完成,但是将这个从主逻辑中分离出来放到一个单独的函数内可能会更好(特别是在需要处理错误的时候),于是我们编写了下述代码
var cwd string  // 包级变量

func init() {
    cwd, err := os.Getwd() // 此处是声明语句,编译错误:cwd declared but not used
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}

解析:

虽然cwd在外部的包级已经声明过,但是:=语句还是将cwd和err重新声明为新的局部变量。因为内部声明的cwd将屏蔽外部的声明,因此上面的代码并不会正确更新包级声明的cwd变量。

由于当前的编译器会检测到局部声明的cwd并没有本使用,然后报告这可能是一个错误,但是这种检测并不可靠。因为一些小的代码变更,例如增加一个局部cwd的打印语句,就可能导致这种检测失效。

var cwd string

func init() {
    cwd, err := os.Getwd() // 此时是声明语句,compile error: unused: cwd
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
    log.Printf("Working directory = %s", cwd)
}

但此时全局的cwd变量依然是没有被正确初始化的,而且看似正常的日志输出更是让这个BUG更加隐晦。

有许多方式可以避免出现类似潜在的问题。最直接的方法是通过单独声明err变量,来避免使用:=的简短声明方式:

var cwd string

func init() {
    var err error
    cwd, err = os.Getwd()  // 此时就完全成了赋值语句,就是更新了包级的cwd
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}

3.6 if的子分支

和for循环类似,if语句也会在条件部分创建隐式词法域,还有它们对应的执行体词法域。

但是与for循环不同的是,if可以有多个子分支,而且多个子分支相当于层层嵌套关系:最开始的if声明语句属于顶级包含在整个判断语句的最外层,分支语句都属于其子级,即if里套了else if,else if里套了else if。。。套在最里层的是else

阅读下述代码,判断结果

package main

import "fmt"

func main() {
    var name string
    fmt.Print("请输入名字:")
    fmt.Scanln(&name)

    if x := name; x == "bigegon" { // 首先可以访问外层的name,然后此处的x声明属于if最外层
        fmt.Println("===>",x)
    } else if y := "smallegonn"; x == y { // 可以访问外层的x
        fmt.Println("--->", y,x)
    } else if z := 1; z > 100 {//上述条件都不成立会执行该分支,虽然该分支条件不成立,但肯定是声明了z
        fmt.Printf("+++>")
    } else {
        fmt.Println("...>",x, y, z) // 可以访问x,y,z
    }
    //fmt.Println(x, y, z) // 无法访问x,y,z
}

懂了上例之后,我们再来看一个例子

if f, err := os.Open(fname); err != nil { // compile error: unused: f
    return err
}
f.ReadByte() // compile error: undefined f
f.Close()    // compile error: undefined f

// 变量f的作用域只有在if语句内,因此后面的语句将无法引入它,这将导致编译错误

通常需要在if之前声明变量,这样可以确保后面的语句依然可以访问变量:

f, err := os.Open(fname)
if err != nil {
    return err
}
f.ReadByte()
f.Close()

你可能会考虑通过将ReadByte和Close移动到if的else块来解决这个问题:

if f, err := os.Open(fname); err != nil {
    return err
} else {
    // f and err are visible here too
    f.ReadByte()
    f.Close()
}

但这不是Go语言推荐的做法,Go语言的习惯是在if中处理错误然后直接返回,这样可以确保正常执行的语句不需要代码缩进。

3.7 switch的子分支

switch的子分支与if类似的是,每个子块都有自己的作用域:条件部分为一个隐式词法域,然后每个是每个分支的词法域。不同的是,子块彼此独立

package main

import "fmt"

func main() {
    m := 10
    switch m := 20; { // 隐式的词法域,末尾必须加分号
    case m > 19:
        var xxx = 111
        fmt.Println(xxx)
        //fallthrough
    case m > 29:
        var yyy = 222
        fmt.Println(yyy)
        //fallthrough
    case m > 39:
        var zzz = 333
        fmt.Println(zzz)
        //fallthrough
    default:
        fmt.Println("===>")
        //fmt.Println(xxx, yyy, zzz)  // 打开所有的fallthrough,也无法访问xxx、yyy
    }

    fmt.Println(m) // 输出10
}

四 练习

练习1:下述代码结果为?

var x int = 111

func main() {
    x=3333
    say()
}

func say() {
    fmt.Println(x)
}

练习2:下述代码结果为?

var x int = 111

func main() {
    x:=222
    {
        x:=333
        {
            x:=444
            fmt.Println(x)
        }
        fmt.Println(x)
    }
    fmt.Println(x)

    say()
}

func say() {
    fmt.Println(x)
}

3、下述三个x是否会冲突,请说明原因

func main() {
    x := "hello"  
    for _, x := range x { 
        x := x + ('A' - 'a')
        fmt.Printf("%c", x) 
    }
}

解析:
例子有三个不同的x变量,每个声明在不同的词法域,一个在函数体词法域,一个在for隐式的初始化词法域,一个在for循环体词法域
func main() {
    x := "hello"  // 1、函数体词法域=>显式的
    for _, x := range x {  // 2、本行代码是初始化词法域=>隐式的
        // 3、此处是循环体词法域=>显式的
        x := x + ('A' - 'a') // 'A' - 'a'为大写字母与小写字母的差值,'B' - 'b'也一样,该行代码的目的是把字母加上一定的偏移量,变为对应的大写字母
        fmt.Printf("%c", x) // "HELLO"
    }
}

4、下述函数打印结果是什么?

package main

import "fmt"

var x int = 111

func f1() {
    fmt.Println("在f1内访问x:",x)
    var x int = 222
    func() { fmt.Println("在f1的子函数内访问x:",x) }()
}

func main() {
    x := 333
    f1()

    fmt.Println("在main中访问x:",x)
}

五 匿名函数和闭包

5.1 匿名函数

匿名函数就是没有名字的函数,用于临时使用一次的场景

func(参数)(返回值){
    函数体
}

匿名函数因为没有函数名,所以没办法像普通函数那样调用,匿名函数需要保存到某个变量或者作为立即执行函数:

func main() {
    // 将匿名函数保存到变量
    add := func(x, y int) {
        fmt.Println(x + y)
    }
    add(10, 20) // 通过变量调用匿名函数

    //自执行函数:匿名函数定义完加()直接执行
    func(x, y int) {
        fmt.Println(x + y)
    }(10, 20)
}

匿名函数多用于实现回调函数和闭包。

5.2 闭包

闭包函数=闭函数+包函数

闭:指的是定义在函数内部的函数,注意,在Go语言中,定义在函数内部的函数必须是匿名函数

包:闭函数包含了对外层函数名字的引用

func counter(start int) func(int) int{
    f:= func(n int) int{
        start += n  // 修改外层函数的变量start
        return start
    }
    return f
}

func main() {
    c:=counter(0)
    fmt.Println(c(1)) // 1
    fmt.Println(c(1)) // 2
    fmt.Println(c(1)) // 3
}

闭包应用示例1

func makeSuffixFunc(suffix string) func(string) string {
    return func(name string) string {
        if !strings.HasSuffix(name, suffix) {
            return name + suffix
        }
        return name
    }
}

func main() {
    jpgFunc := makeSuffixFunc(".jpg")
    txtFunc := makeSuffixFunc(".txt")
    fmt.Println(jpgFunc("test")) //test.jpg
    fmt.Println(txtFunc("test")) //test.txt
}

闭包应用示例2

func circle(radius float64) (func(), func()) {
    perimeter := func() {
        fmt.Println("周长是:",2 * math.Pi * radius)
    }
    area := func() {
        fmt.Println("面积是:",math.Pi * (radius * radius))
    }
    return perimeter, area
}

func main() {
    f1,f2 := circle(3.3)
    f1()  // 周长是: 20.734511513692635
    f2()  // 面积是: 34.21194399759284
}

练习:

v := "outer"
fmt.Println(v)
{
    v := "inner"
    fmt.Println(v)
    {
        fmt.Println(v)
    }
}
{
    fmt.Println(v)
}
fmt.Println(v)

输出:
outer
inner
inner
outer
outer
上一篇
下一篇
Copyright © 2022 Egon的技术星球 egonlin.com 版权所有 帮助IT小伙伴学到真正的技术