掘金 后端 ( ) • 2024-05-07 23:34

highlight: a11y-dark theme: z-blue

使用 defer 测量函数的执行时间

这里有一个简单的辅助工具,通过使用 defer,仅用一行就能测量函数运行所需的时间。

这种方法在快速调试和开发时非常有用,但请记住,在实际使用前可能需要进行一些调整。

func main() {
    defer TrackTime(time.Now()) // <--- THIS

    time.Sleep(500 * time.Millisecond)
}

func TrackTime(pre time.Time) time.Duration {
    elapsed := time.Since(pre)
    fmt.Println("elapsed:", elapsed)

    return elapsed
}

// elapsed: 501.11125ms

这种方法之所以行之有效,是因为 defer 如何处理其参数。

当我们使用 defer 语句时,它会立即计算出参数的值,但直到它所在函数的最后,即该函数中的其他所有事情都已发生之后,它才会真正调用函数。

在这种情况下,它会一直等到主函数即将结束。这样,记录的经过时间就包括了从函数开始到结束的所有操作,从而让你完整地了解函数的执行时间。

多级延迟: 高效处理函数的开始和结束

当我们谈论多阶段延迟时,我们正在研究一种有效管理函数开始和结束的方法,而不需要使用代码的人手动处理这些方面。

我们可以在 Go 中使用 defer 来确保在函数开始和结束时执行某些操作。

func main() {
    defer MultistageDefer()() // <-------

    fmt.Println("Main function called")
}

func MultistageDefer() func() {
    fmt.Println("Run initialization")

    return func() {
        fmt.Println("Run cleanup")
    }
}

// Output:
// Run initialization
// Main function called
// Run cleanup

这种技术对于管理资源(如数据库连接)特别有用,它可以在开始时进行设置,并确保在结束时正确关闭。这种设置使用户不必记住自己关闭连接。

通过简单的设置,它还可用于跟踪函数的运行时间

使结构体不可比较

如果结构体中的每个字段都是可比较的,那么结构体本身也是可比较的,这意味着我们可以使用 == 和 != 操作符直接比较两个结构体:

type Point struct {
    X, Y float64
}

p1 := Point{1.0, 2.0}
p2 := Point{1.0, 2.0}

fmt.Println(p1 == p2) // true

但是,直接比较包含浮点数的结构体可能会导致问题,因为浮点数值由于其固有的精度限制,最好使用近似值进行比较。

我们的团队可能更倾向于使用自定义的 .Equals() 方法进行比较:

func (p Point) Equals(other Point) bool {
    return math.Abs(p.X - other.X) < 1e-9 && math.Abs(p.Y - other.Y) < 1e-9
}

但是,让我们考虑一下:使用 p1 == p2 简单、快捷,而且对于那些可能不完全了解其中的细微差别或可能没有通读类型的全部方法的人来说,实在是太有诱惑力了。

为了确保每个开发人员都能始终如一地使用您定义的比较方法,这里介绍一种零成本策略,让您的结构体具有不可比较性:

type Point struct {
    _     [0]func()
    X, Y  float64
}

这个 [0] func() 字段有三个关键属性:

  • 它是未导出的,因此不会被结构体用户发现,从而防止了外部操作。
  • 它是零宽度或无成本的,这意味着这个数组不占用内存空间,因为它的长度为零。
  • 它不可比较,func() 是一种函数类型,而函数在 Go 中是不可比较的,这反过来又使得结构体不可比较。 当涉及到 [0]func() 时,如果尝试使用 == 或 != 操作符直接比较两个实例,确实会导致编译时错误。

现在,值得注意的是 [0]func() 在结构体中的位置,尽管它实际上不占用任何空间,但它的位置会影响结构体的整体大小。

例如,如果您查看这个代码:

// 16 bytes
type Point struct {
    _     [0]func()
    X, Y  float64
}

// 24 bytes
type Point struct {
    X, Y  float64
    _     [0]func()
}

您可以看到 [0]func() 的位置如何改变结构体的大小,这一讨论非常相关,可以在 GitHub issue runtime: pointer to struct field can point beyond struct allocation #9401 中进一步探讨。

为了使问题更清晰易懂,尤其是在通知我们的团队时,我们可以定义一种自定义类型来封装这种不可比较的特性,类似下面这样:

type nonComparable [0]func()

type Point struct {
    nonComparable
    X, Y  float64
}

函数调用中的结果

刚开始使用 Go 时,我们可能会觉得有一个概念有点棘手:函数调用中的结果。

在 Go 中,从函数中接收多个值是很常见的,对吗?通常是一个结果和一个错误。让我们来看一个典型的例子,在函数结束时,我们会处理一个函数的输出,比如 processResult(result):

func doSomething() (int, error) {
    ...

    return result, nil
}

func main() {
    result, err := doSomething() 
    if err != nil {
        log.Fatal(err)
    }

    processResult(result)
}

有趣的是,如果结果直接符合 processResult 函数的预期类型,我们就可以通过直接传递结果来缩短代码:

func main() {
-   result, err := doSomething() 
-   if err != nil {
-       log.Fatal(err)
-   }
-   processResult(result)
+   processResult(doSomething())
}

func processResult(result int, err error) {
    ...
}

现在,让我们考虑这样一种情况:我们收到一个结果和一个错误,在此基础上,我们需要向客户端发送一个带有适当状态代码的响应。

在许多应用程序接口层中,我们会发现许多控制器或函数都在处理这种模式。它们处理(结果、错误)元组,如果出现错误,则返回 400 状态代码,如果一切正常,则返回 200 OK:

func GetResult(api *API) {
    result, err := Response()
    if err != nil {
        api.JSON(http.StatusBadRequest, err)
        return
    }

    api.JSON(http.StatusOK, result)
}

与在各种函数中重复应用相同的模式相比,我们可以通过实施一种标准化方法来处理这些基于函数调用的响应,从而大大减少代码量:

func GetResult(api *API) {
    api.JSONWithStatus(Response())
}

func (api *API) JSONWithStatus(result any, err error) {
    if err != nil {
        api.JSON(http.StatusBadRequest, err)
        return
    }

    api.JSON(http.StatusOK, result)
}

此外,我还有一个小小的通用辅助函数,在没有错误的情况下返回结果,在有错误的情况下停止进程:

func Must[T any](result T, err error) T { ... }

当然,不要过度使用这种模式。

虽然当你的代码库中有一个清晰、重复的模式时,比如我们讨论过的 Must 或 JSONWithStatus 函数,这种模式非常有用,但它也可能是一个糟糕的选择,因为将错误处理隐藏在实用程序中有时会掩盖代码的实际工作。

轻松使用泛型返回指针

对于经常需要从函数返回值中获取指针的 Go 编程人员来说,这里有一个方便的小技巧。

过去,我们可能会用几种方法来处理这个问题,也许我们会这样做:

result := getData() 
ptr := &result

或者,你试图用一种 “巧妙 ”的变通方法将流程简化为一行:

ptr := func(t Data) *Data { return &t }(getData())

虽然这种方法可以完成工作,但可能有些啰嗦,尤其是在处理多种数据类型时,您会发现自己在重复这一过程。

现在,让我们来讨论使用泛型的更优化、更 “时尚 ”的解决方案:

func Ptr[T any](v T) *T {
    return &v
}

通过这个简单的函数,我们可以为任何类型的值生成一个指针,而无需重复编码。我们只需将值传递给 Ptr 函数,它就会返回所需的指针。

下面是使用方法:

timePtr := Ptr(time.Now())

intPtr := Ptr(42)

strPtr := Ptr("hello")

编译时使用接口验证

让我们来讨论一个与我们在 Go 中使用接口的人有共鸣的话题,尤其是在编译时确保结构体正确实现接口的重要性。而且,我们有一个很好的中心位置来检查它,而不是在代码库中四处寻找。

考虑到我们有一个需要 Write() 函数的 Buffer 接口,我们创建了一个 StringBuffer 结构来实现该接口。

type Buffer interface {
    Write(p []byte) (n int, err error)
}

type StringBuffer struct{}

不过,假设我们不小心在方法名称中引入了一个错字,例如用 Writeee() 代替 Write(),如图所示:

func (s *StringBuffer) Writeee(p []byte) (n int, err error) {
}

为帮助在编译时更及时地捕捉此类错误,可以使用一种简单而有效的方法:

// syntax: var _ <interface> = (*type)(nil)
var _ Buffer = (*StringBuffer)(nil)

这行代码不会改变运行时的行为,但会强制 Go 编译器检查 *StringBuffer 是否真正满足 Buffer 接口。

如果 StringBuffer 无法正确实现 Buffer 中定义的所有方法,编译器会立即标记错误并告诉我们缺少了什么。

// cannot use (*StringBuffer)(nil) (value of type
// *StringBuffer)
// as Buffer value in variable declaration: *StringBuffer
// does not implement Buffer (missing method Write)

使用空字段的结构体

这里的主要目标是确保当有人使用我们的软件包并决定创建我们结构的实例时,他们必须在结构字面中使用命名字段。

下面是这样一种情况:我们定义了一个带有两个字段的 Point 结构: X 和 Y。

// package math
type Point struct {
    X float64
    Y float64
}

a := math.Point{1, 4}

使用这种方法来实例化结构,对于像点结构(Point struct)这样简单的结构(只包括 X 和 Y)来说,通常不会有什么问题。

假设我们决定通过添加额外字段(如字符串 “label ”字段)来扩展点结构体。

如果进行了这样的更改,您的库用户编写的任何现有代码如果没有更新以包含新字段,都将无法编译。他们会遇到类似 “config.Point 类型的 struct literal 中值太少 ”的错误,从而导致向后兼容性问题。

为了鼓励用户明确定义 X 和 Y 这样的字段,这里有一个策略:在结构体中添加一个非导出和零大小的特殊变量。

零大小类型的常见选择包括空结构体(struct{})和零长度数组:

type Point struct {
    _ struct{}
    X float64
    Y float64
}

a := math.Point{X: 1, Y: 4}
b := Point{1, 4} // compile-time error

这种设置本质上是一种信号 “这个点不只是X和Y 还有更多”

为什么要使用非导出、零大小的字段?

使用非导出、零大小字段是一种巧妙的策略。

  • 非导出的特性使其在包外无法访问,从而保持了封装性。
  • 由于它的大小为零,因此不会给结构体增加任何内存开销。
  • 如果觉得 _ struct{} 语法过于晦涩难懂,不能明确表达防止无键字面形式的意图,那么还有另一种方法:
type noUnkeyed struct {}

type Point struct {
    noUnkeyed
    X float64
    Y float64
}