Golang拾遗之实现一个不可复制类型详解

目录
  • 如何复制一个对象
  • 为什么要禁止复制
  • 运行时检测实现禁止复制
    • 初步尝试
    • 更好的实现
    • 性能
    • 优点和缺点
  • 静态检测实现禁止复制
    • 利用Locker接口不可复制实现静态检测
    • 优点和缺点
  • 更进一步
    • 利用package和interface进行封装
    • 优点和缺点
  • 总结

如何复制一个对象

不考虑IDE提供的代码分析和go vet之类的静态分析工具,golang里几乎所有的类型都能被复制。

// 基本标量类型和指针
var i int = 1
iCopy := i
str := "string"
strCopy := str

pointer := &i
pointerCopy := pointer
iCopy2 := *pointer // 解引用后进行复制

// 结构体和数组
arr := [...]int{1, 2, 3}
arrCopy := arr

type Obj struct {
    i int
}
obj := Obj{}
objCopy := obj

除了这些,golang还有函数和引用类型(slice、map、interface),这些类型也可以被复制,但稍有不同:

func f() {...}
f1 := f
f2 := f1

fmt.Println(f1, f2) // 0xabcdef 0xabcdef 打印出来的值是一样的
fmt.Println(&f1 == &f2) // false 虽然值一样,但确实是两个不同的变量

这里并没有真正复制处三份f的代码,f1和f2均指向f,f的代码始终只会有一份。map、slice和interface与之类似:

m := map[int]string{
    0: "a",
    1: "b",
}
mCopy := m // 两者引用同样的数据
mCopy[0] := "unknown"
m[0] == "unknown" // True
// slice的复制和map相同

interface是比较另类的,它的行为要分两种情况:

s := "string"
var i1 any = s
var i2 any = s
// 当把非指针和接口类型的值赋值给interface,会导致原来的对象被复制一份

s := "string"
var i1 any = s
var i2 any = i2
// 当把接口赋值给接口,底层引用的数据不会被复制,i1会复制s,i2此时和i1共有一个s的副本

ss := "string but pass by pointer"
var i3 any = &ss
var i4 any = i3
// i3和i4均引用ss,此时ss没有被复制,但指向ss的指针的值被复制了两次

上面的结果会一定程度上被编译优化干扰,比如少数情况下编译器可以确认赋值给接口的值从来没被修改并且生命周期不比源对象长,则可能不会进行复制。

所以这里有个小提示:如果要赋值给接口的数据比较大,那么最好以指针的形式赋值给接口,复制指针比复制大量的数据更高效。

为什么要禁止复制

从上一节可以看到,允许复制时会在某些情况下“闯祸”。比如:

1.浅拷贝的问题很容易出现,比如例子里的map和slice的浅拷贝问题,这可能会导致数据被意外修改

2.意外复制了大量数据,导致性能问题

3.在需要共享状态的地方错误的使用了副本,导致状态不一致从而产生严重问题,比如sync.Mutex,复制一个锁并使用其副本会导致死锁

4.根据业务或者其他需求,某类型的对象只允许存在一个实例,这时复制显然是被禁止的

显然在一些情况下禁止复制是合情合理的,这也是为什么我会写这篇文章。

但具体情况具体分析,不是说复制就是万恶之源,什么时候该支持复制,什么时候应该禁止,应该结合自己的实际情况。

运行时检测实现禁止复制

想在别的语言中禁止某个类型被复制,方法有很多,用c++举一例:

struct NoCopy {
    NoCopy(const NoCopy &) = delete;
    NoCopy &operator=(const NoCopy &) = delete;
};

可惜在golang里不支持这么做。

另外,因为golang没有运算符重载,所以很难在赋值的阶段就进行拦截,所以我们的侧重点在于“复制之后可以尽快检测到”。

所以我们先实现在对象被复制后报错的功能。虽然不如c++编译期就可以禁止复制那样优雅,但也算实现了功能,至少不什么都没有要强一些。

初步尝试

那么如何直到对象是否被复制了?很简单,看它的地址就行了,地址一样那必然是同一个对象,不一样了那说明复制出一个新的对象了。

顺着这个思路,我们需要一个机制来保存对象第一次创建时的地址,并在后续进行比较,于是第一版代码诞生了:

import "unsafe"

type noCopy struct {
    p uintptr
}

func (nc *noCopy) check() {
    if uintptr(unsafe.Pointer(nc)) != nc.p {
        panic("copied")
    }
}

逻辑比较清晰,每次调用check来检查当前的调用者的地址和保存地址是否相同,如果不同就panic。

为什么没有创建这个类型的方法?因为我们没法得知自己被其他类型创建时的地址,所以这块得让其他使用noCopy的类型代劳。

使用的时候需要把noCopy嵌入自己的struct,注意不能以指针的形式嵌入:

type SomethingCannotCopy struct {
    noCopy
    ...
}

func (s *SomethingCannotCopy) DoWork() {
    s.check()
    fmt.Println("do something")
}

func NewSomethingCannotCopy() *SomethingCannotCopy {
    s := &SomethingCannotCopy{
        // 一些初始化
    }
    // 绑定地址
    s.noCopy.p = unsafe.Pointer(&s.noCopy)
    return s
}

注意初始化部分的代码,在这里我们需要把noCopy对象的地址绑定进去。现在可以实现运行时检测了:

func main() {
    s1 := NewSomethingCannotCopy()
    pointer := s1
    s1Copy := *s1 // 这里实际上进行了复制,但需要调用方法的时候才能检测到
    pointer.DoWork() // 正常打印出信息
    s1Copy.DoWork() // panic
}

解释下原理:当SomethingCannotCopy被复制的时候,noCopy也会被复制,因此复制出来的noCopy的地址和原先的那个是不一样的,但他们内部记录的p是一样的,这样当被复制出来的noCopy对象调用check方法的时候就会触发panic。这也是为什么不要用指针形式嵌入它的原因。

功能实现了,但代码实在是太丑,而且耦合严重:只要用了noCopy,就必须在创建对象的同时初始化noCopy的实例,noCopy的初始化逻辑会侵入到其他对象的初始化逻辑中,这样的设计是不能接受的。

更好的实现

那么有没有更好的实现?答案是有的,而且在标准库里。

标准库的信号量sync.Cond是禁止复制的,而且比Mutex更为严格,因为复制它比复制锁更容易导致死锁和崩溃,所以标准库加上了运行时的动态检查。

主要代码如下:

type Cond struct {
    // L is held while observing or changing the condition
    L Locker
    ...
    // 复制检查
    checker copyChecker
}

// NewCond returns a new Cond with Locker l.
func NewCond(l Locker) *Cond {
        return &Cond{L: l}
}

func (c *Cond) Signal() {
    // 检查自己是否被复制
    c.checker.check()
    runtime_notifyListNotifyOne(&c.notify)
}

checker实现了运行时检测是否被复制,但初始化的时候并不需要特殊处理这个checker,这是用了什么手法做到的呢?

看代码:

type copyChecker uintptr

func (c *copyChecker) check() {
    if uintptr(*c) != uintptr(unsafe.Pointer(c)) && // step 1
            !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) && // step 2
            uintptr(*c) != uintptr(unsafe.Pointer(c)) { //step 3
        panic("sync.Cond is copied")
    }
}

看着很复杂,连原子操作都来了,这都是啥啊。但别怕,我给你捋一捋就明白了。

首先是checker初始化之后第一次调用:

  • 当check第一次被调用,c的值肯定是0,而这时候c是有真实的地址的,所以step 1失败,进入step 2;
  • 用原子操作把c的值设置成自己的地址值,注意只有c的值是0的时候才能完成设置,因为这里c的值是0,所以交换成功,step 2是False,判断流程直接结束;
  • 因为不排除还有别的goroutine拿着这个checker在做检测,所以step 2是会失败的,这是要进入step 3;
  • step 3再次比较c的值和它自己的地址是否相同,相同说明多个goroutine共用了一个checker,没有发生复制,所以检测通过不会panic。
  • 如果step 3的比较发现不相等,那么说明被复制了,直接panic

然后我们再看其他情况下checker的流程:

  • 这时候c的值不是0,如果没发生复制,那么step 1的结果是False,判断流程结束,不会panic;
  • 如果c的值和自己的地址不一样,会进入step 2,因为这里c的值不为0,所以表达式结果一定是True,所以进入step 3;
  • step 3和step 1一样,结果是True,地址不同说明被复制,这时候if里面的语句会执行,因此panic。

搞得这么麻烦,其实就是为了能干干净净地初始化。这样任何类型都只需要带上checker作为自己的字段就行,不用关心它是这么初始化的。

还有个小问题,为什么设置checker的值需要原子操作,但读取就不用呢?

因为读取一个uintptr的值,在现代的x86和arm处理器上只要一个指令,所以要么读到过时的值要么读到最新的值,不会读到错误的或者写了一半的不完整的值,对于读到旧值的情况(主要出现在第一次调用check的时候),还有step 3做进一步的检查,因此不会影响整个检测逻辑。而“比较并交换”显然一条指令做不完,如果在中间步骤被打断那么整个操作的结果很可能就是错的,从而影响整个检测逻辑,所以必须要用原子操作才行。

那么在读取的时候也使用atomic.Load行吗?当然行,但一是这么做仍然避免不了step 3的检测,可以思考下是为什么;二是原子操作相比直接读取会带来性能损失,在这里不使用原子操作也能保证正确性的情况下这是得不偿失的。

性能

因为是运行时检测,所以我们得看看会对性能带来多少影响。我们使用改进版的checker。

type CheckBench struct {
    num uint64
    checker copyChecker
}

func (c *CheckBench) CheckCopy() {
    c.checker.check()
    c.num++
}

// 不进行检测
func (c *CheckBench) NoCheck() {
    c.num++
}

func BenchmarkCheckBench_NoCheck(b *testing.B) {
    c := CheckBench{}
    for i := 0; i < b.N; i++ {
        for j := 0; j < 50; j++ {
            c.NoCheck()
        }
    }
}

func BenchmarkCheckBench_WithCheck(b *testing.B) {
    c := CheckBench{}
    for i := 0; i < b.N; i++ {
        for j := 0; j < 50; j++ {
            c.CheckCopy()
        }
    }
}

测试结果如下:

cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_NoCheck-8           17689137                68.36 ns/op
BenchmarkCheckBench_WithCheck-8         17563833                66.04 ns/op

几乎可以忽略不计,因为我们这里没有发生复制,所以几乎每次检测都是通过的,这对cpu的分支预测非常友好,所以性能损耗几乎可以忽略。

所以我们给cpu添点堵,让分支预测没那么容易:

func BenchmarkCheckBench_WithCheck(b *testing.B) {
    for i := 0; i < b.N; i++ {
        c := &CheckBench{}
        for j := 0; j < 50; j++ {
            c.CheckCopy()
        }
    }
}

func BenchmarkCheckBench_NoCheck(b *testing.B) {
    for i := 0; i < b.N; i++ {
        c := &CheckBench{}
        for j := 0; j < 50; j++ {
            c.NoCheck()
        }
    }
}

现在分支预测没那么容易了而且要多付出初始化时使用atomic的代价,测试结果会变成这样:

cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_WithCheck-8         15552717                74.84 ns/op
BenchmarkCheckBench_NoCheck-8           26441635                44.74 ns/op

差不多会慢40%。当然,实际的代码不会有这么极端,所以最坏可能也只会产生20%的影响,通常不太会成为性能瓶颈,运行时检测是否有影响还需结核profile。

优点和缺点

优点:

  • 只要调用check,肯定能检查出是否被复制
  • 简单

缺点:

  • 所有的方法里都需要调用check,新加方法忘了调用的话就无法检测
  • 只能在被复制出来的新对象那检测到复制操作,原先那个对象上check始终是没问题的,这样不是严格禁止了复制,但大多数时间没问题,可以接受
  • 如果只复制了对象没调用任何对象上的方法,也无法检测到复制,这种情况比较少见
  • 有潜在性能损耗,虽然很多时候可以得到充分优化损耗没那么夸张

静态检测实现禁止复制

动态检测的缺点不少,能不能像c++那样编译期就禁止复制呢?

利用Locker接口不可复制实现静态检测

也可以,但得配合静态代码检测工具,比如自带的go vet。看下代码:

// 实现sync.Locker接口
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}

type SomethingCannotCopy struct {
    noCopy
}

这样就行了,不需要再添加其他的代码。解释下原理:任何实现了sync.Locker的类型都不应该被拷贝,静态代码检测会检测出这些情况并报错。

所以类似下边的代码都是无法通过静态代码检测的:

func f(s SomethingCannotCopy) {
    // 报错,因为参数会导致复制
    // 返回SomethingCannotCopy也是不行的
}

func (s SomethingCannotCopy) Method() {
    // 报错,因为非指针类型接收器会导致复制
}

func main() {
    s := SomethingCannotCopy{}
    sCopy := s // 报错
    sInterface := any(s) // 报错
    sPointer := &s // OK
    sCopy2 := *sPointer // 报错
    sInterface2 := any(sPointer) // OK
    sCopy3 := *(sInterface2.(*SomethingCannotCopy)) // 报错
}

基本上涵盖了所以会产生复制操作的地方,基本能在编译期完成检测。

如果跳过go vet,直接使用go run或者go build,那么上面的代码可以正常编译并运行。

优点和缺点

因为只有静态检测,因此没有什么运行时开销,所以性能这节就不需要费笔墨了。主要来看下这种方案的优缺点。

优点:

  • 实现非常简单,代码很简练,基本无侵入性
  • 依赖静态检测,不影响运行时性能
  • golang自带检测工具:go vet
  • 可检测到的case比运行时检测多

缺点:

  • 最大的缺点,尽管静态检测会报错,但仍然可以正常编译执行
  • 不是每个测试环境和CI都配备了静态检测,所以很难强制保证类型没有被复制
  • 会导致类型实现sync.Locker,然而很多时候我们的类型并不是类似锁的资源,使用这个接口只是为了静态检测,这会带来代码被误用的风险

标准库也使用的这套方案,建议仔细阅读这个issue里的讨论。

更进一步

看过运行时检测和静态检测两种方案之后,我们会发现这些做法多少都有些问题,不尽如人意。

所以我们还是要追求一种更好用的,更符合golang风格的做法。幸运的是,这样的做法是存在的。

利用package和interface进行封装

首先我们创建一个worker包,里面定义一个Worker接口,包中的数据对外以Worker接口的形式提供:

package worker

import (
    "fmt"
)

// 对外只提供接口来访问数据
type Worker interface {
    Work()
}

// 内部类型不导出,以接口的形式供外部使用
type normalWorker struct {
    // data members
}
func (*normalWorker) Work() {
    fmt.Println("I am a normal worker.")
}
func NewNormalWorker() Worker {
    return &normalWorker{}
}

type specialWorker struct {
    // data members
}
func (*specialWorker) Work() {
    fmt.Println("I am a special worker.")
}
func NewSpecialWorker() Worker {
    return &specialWorker{}
}

worker包对外只提供Worker接口,用户可以使用NewNormalWorker和NewSpecialWorker来生成不同种类的worker,用户不需要关心具体的返回类型,只要使用得到的Worker接口即可。

这么做的话,在worker包之外是看不到normalWorker和specialWorker这两个类型的,所以没法靠反射和类型断言取出接口引用的数据;因为我们传给接口的是指针,因此源数据不会被复制;同时我们在第一节提到过,把一个接口赋值给另一个接口(worker包之外你只能这么做),底层被引用的数据不会被复制,因此在包外始终不会在这两个类型上产生复制的行为。

因此下面这样的代码是不可能通过编译的:

func main() {
    w := worker.NewSpecialWorker()
    // worker.specialWorker 在worker包以外不可见,因此编译错误
    wCopy := *(w.(*worker.specialWorker))
    wCopy.Work()
}

优点和缺点

这样就实现了worker包之外的禁止复制,下面来看看优缺点。

优点:

  • 不需要额外的静态检查工具在编译代码前执行检查
  • 不需要运行时动态检测是否被复制
  • 不会实现自己不需要的接口类型导致污染方法集
  • 符合golang开发中的习惯做法

缺点:

  • 并没有让类型本身不可复制,而是靠封装屏蔽了大部分可能导致复制的情况
  • 这些worker类型在包内是可见的,如果在包内修改代码时不注意可能会导致复制这些类型的值,所以要么包内也都用Woker接口,要么参考上一节添加静态检查
  • 有些场景下不需要接口或者因为性能要求苛刻而使用不了接口,这种做法就行不通了,比如标准库sync里的类型为了性能大部分都是暴露出来给外部直接使用的

综合来说,这种方案是实现成本最低的。

总结

现在我们有三种方式防止我们的类型被复制:

  • 运行时检测
  • 静态代码检测
  • 通过接口封装避免暴露类型,从而避免被复制

一共三种方案,选择困难症仿佛要发作了。别着急,我们一起看看标准库是怎么做的:

  • 标准库的sync.Cond同时使用了方案一和方案二,因为设计者确实很不希望条件变量被复制
  • sync.Mutex、sync.Pool和sync.WaitGroup使用了方案二,需要配合go vet
  • 方案三在标准库中应用最广泛,然而多数是处于设计和封装的考虑,并不是为了禁止copy,但复制crypto包下的那些Hash和Cipher确实没什么意义会带来误用,正好借着方案三避免了这些问题

综合来看首选的应该是方案三;但也有需要使用方案二的时候,比如sync包中的那些同步机构;使用最少的是方案一,尽可能地不要设计出类似的代码。

还有一点需要注意,如果你的类型里有字段是sync.Pool、sync.WaitGroup、sync.RWMutex、sync.Mutex、sync.Cond、sync.Map或sync.Once,那么这个类型本身也是不可复制的,也不需要额外实现禁止复制的功能,因为那些字段自带了。

最后,我只想说golang的语言技能实在是太简陋了,想只依赖语言特性实现禁止复制的功能不太现实,更多的还是需要靠“设计”。

以上就是Golang拾遗之实现一个不可复制类型详解的详细内容,更多关于Golang不可复制类型的资料请关注我们其它相关文章!

(0)

相关推荐

  • GO语言实现简单的目录复制功能

    本文实例讲述了GO语言实现简单的目录复制功能.分享给大家供大家参考.具体实现方法如下: 创建一个独立的 goroutine 遍历文件,主进程负责写入数据.程序会复制空目录,也可以设置只复制以 ".xx" 结尾的文件. 严格来说这不是复制文件,而是写入新文件.因为这个程序是创建新文件,然后写入复制数据的.我们一般的 copy 命令是不会修改文件的 ctime(change time) 状态的. 代码如下: 复制代码 代码如下: // 一个简单的目录复制程序:一个独立的 goroutine

  • golang 实现两个结构体复制字段

    实际工作中可能会有这样的场景: 两个结构体(可能类型一样), 字段名和类型都一样, 想复制一个结构体的全部或者其中某几个字段的值到另一个(即merge操作), 自然想到可以用反射实现 package main import "fmt" import "reflect" // 用b的所有字段覆盖a的 // 如果fields不为空, 表示用b的特定字段覆盖a的 // a应该为结构体指针 func CopyFields(a interface{}, b interface

  • Golang 实现复制文件夹同时复制文件

    Golang 复制文件夹,包括文件夹中的文件 /** * 拷贝文件夹,同时拷贝文件夹中的文件 * @param srcPath 需要拷贝的文件夹路径: D:/test * @param destPath 拷贝到的位置: D:/backup/ */ func CopyDir(srcPath string, destPath string) error { //检测目录正确性 if srcInfo, err := os.Stat(srcPath); err != nil { fmt.Println(

  • Golang拾遗之实现一个不可复制类型详解

    目录 如何复制一个对象 为什么要禁止复制 运行时检测实现禁止复制 初步尝试 更好的实现 性能 优点和缺点 静态检测实现禁止复制 利用Locker接口不可复制实现静态检测 优点和缺点 更进一步 利用package和interface进行封装 优点和缺点 总结 如何复制一个对象 不考虑IDE提供的代码分析和go vet之类的静态分析工具,golang里几乎所有的类型都能被复制. // 基本标量类型和指针 var i int = 1 iCopy := i str := "string" st

  • golang时间处理工具箱now的使用详解

    golang不像C#,Java这种高级语言,有丰富的语法糖供开发者很方便的调用.所以这便催生出很多的开源组件,通过使用这些第三方组件能够帮助我们在开发过程中少踩很多的坑. 时间处理是所有语言都要面对的一个问题,parse根据字符串转为date类型,tostring()将date类型转为定制化的字符串. 在实际使用过程中,parse的使用有一种不是很舒服的方法. 上源码 time1, _ := time.Parse("2006-01-02", "2020-02-22"

  • Golang配置解析神器go viper使用详解

    目录 前言 viper简介 功能 viper配置优先级 安装viper 支持哪些文件格式 key大小写问题 使用指南 如何访问viper的功能 配置默认值 读取配置文件 写配置文件 WriteConfig SafeWriteConfig WriteConfigAs SafeWriteConfigAs 监听配置文件 从io.Reader读取配置 显示设置配置项 注册和使用别名 读取环境变量 与命令行参数搭配使用 pflag 扩展其他flag 远程key/value存储支持 访问配置 直接访问 序列

  • Golang学习之反射机制的用法详解

    目录 介绍 TypeOf() ValueOf() 获取接口变量信息 事先知道原有类型的时候 事先不知道原有类型的时候 介绍 反射的本质就是在程序运行的时候,获取对象的类型信息和内存结构,反射是把双刃剑,功能强大但可读性差,反射代码无法在编译阶段静态发现错误,反射的代码常常比正常代码效率低1~2个数量级,如果在关键位置使用反射会直接导致代码效率问题,所以,如非必要,不建议使用. 静态类型是指在编译的时候就能确定的类型(常见的变量声明类型都是静态类型):动态类型是指在运行的时候才能确定的类型(比如接

  • Golang基础教程之字符串string实例详解

    目录 1. string的定义 2.string不可变 3.使用string给另一个string赋值 4.string重新赋值 补充:字符串拼接 总结 1. string的定义 Golang中的string的定义在reflect包下的value.go中,定义如下: StringHeader 是字符串的运行时表示,其中包含了两个字段,分别是指向数据数组的指针和数组的长度. // StringHeader is the runtime representation of a string. // I

  • Golang 实现 RTP音视频传输示例详解

    目录 引言 RTP 数据包头部字段 Golang 的相关实现 结尾 引言 在 Coding 之前我们先来简单介绍一下 RTP(Real-time Transport Protocol), 正如它的名字所说,用于互联网的实时传输协议,通过 IP 网络传输音频和视频的网络协议. 由音视频传输工作小组开发,1996 年首次发布,并提出了以下使用设想. 简单的多播音频会议 使用 IP 的多播服务进行语音通信.通过某种分配机制,获取多播组地址和端口对.一个端口用于音频数据的,另一个用于控制(RTCP)包,

  • Golang 中的 unsafe.Pointer 和 uintptr详解

    目录 前言 uintptr unsafe.Pointer 使用姿势 常规类型互转 Pointer => uintptr 指针算数计算:Pointer => uintptr => Pointer reflect 包中从 uintptr => Ptr 实战案例 string vs []byte sync.Pool 前言 日常开发中经常看到大佬们用各种 unsafe.Pointer, uintptr 搞各种花活,作为小白一看到 unsafe 就发憷,不了解二者的区别和场景,自然心里没数.

  • 三种Golang数组拷贝方式及性能分析详解

    目录 测试 测试代码 测试结果 原理分析 copy append 总结 在Go语言中,我们可以使用for.append()和copy()进行数组拷贝,对于某些对性能比较敏感且数组拷贝比较多的场景,我们可以会对拷贝性能比较关注,这篇文件主要是对比一下这三种方式的性能. 测试 测试条件是把一个64KB的字节数组分为64个块进行复制. 测试代码 package test import ( "testing" ) const ( blocks = 64 blockSize = 1024 ) v

  • Golang 官方依赖注入工具wire示例详解

    目录 依赖注入是什么 开源选型 wire providers injectors 类型区分 总结 依赖注入是什么 Dependency Injection is the idea that your components (usually structs in go) should receive their dependencies when being created. 在 Golang 中,构造一个结构体常见的有两种方式: 在结构体初始化过程中,构建它的依赖: 将依赖作为构造器入参,传入进

  • Golang中的错误处理的示例详解

    目录 1.panic 2.包装错误 3.错误类型判断 4.错误值判断 1.panic 当我们执行panic的时候会结束下面的流程: package main import "fmt" func main() { fmt.Println("hello") panic("stop") fmt.Println("world") } 输出: go run 9.go hellopanic: stop 但是panic也是可以捕获的,我们可

随机推荐