Golang中不修改对象的方法是否应该设计为函数?

Golang中不修改对象的方法是否应该设计为函数? 我在听一个2015年的Go演讲,演讲者提到那些满足以下条件的方法:

  • 仅使用对象状态作为输入
  • 不改变对象状态
  • 对于相同的输入总是返回相同的结果(不使用随机数)

最好将它们写成以该对象为参数的函数。在过去的6年里,大家的普遍共识改变了吗?

3 回复

avatar

感谢 @skillian 提供的详细解答。我会查看你的代码仓库,以便更好地理解你的意思。

更多关于Golang中不修改对象的方法是否应该设计为函数?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


我无法代表他人,只能谈谈自己的看法。就我个人而言,我不会使用那些标准来判断某个东西是函数还是方法。回顾我的一些个人项目,我似乎只在以下情况下会使用方法而非函数:

  1. 为了实现一个接口,或者
  2. 如果对象及其方法本身就是API。

除此之外,我倾向于编写接受接口的函数。

我不知道如何更好地描述第2点,但我可以举一些例子:

  • 几年前,我创建了一个日志记录包,旨在与Python的日志记录包类似地工作。记录器可以关联处理器,而处理器拥有格式化器,用于格式化发送到记录器的事件。

    *Logger 类型上有许多方法,如 VerboseInfoWarn 等,用于记录具有相应严重性级别的消息。记录器会检查事件的严重性是否大于或等于记录器自身的最低严重性级别,如果是,则将事件分派给其所有的处理器,处理器在用自己的格式化器格式化事件并按照自己的方式发出之前,会依次比较事件的严重性级别与其自身的严重性级别。

    我解释所有这些是因为对象及其关系本身就是API。处理器不是记录器的实现细节;它们是其API的关键部分。格式化器也是如此。

  • 我在一个参数解析包中也做了类似的事情,它同样具有对象作为API一部分的概念:首先你创建一个 ArgumentParser,向其中添加 Argument,然后使用 ArgumentParser 来解析参数并获取结果值。

地道的Go代码似乎试图避免这样的代码。你拥有的公共类型越多,这些类型上的公共方法越多,重构它们就越困难,特别是如果你的自定义类型具有导出方法,而这些方法本身返回自定义类型。例如,在我上面提到的那个日志记录包中,格式化器的目的是将事件对象格式化为字符串。这在某些情况下会带来问题,例如在结构化日志记录中,期望的日志输出是机器可读、可解析和可查询的格式,而不是人类可读的字符串。通过将格式化器作为公共API的一部分,我限制了可能的处理器实现,或者要求它们暴露虚拟的 FormatterSetFormatter 方法。

在Go语言中,关于不修改对象状态的方法是否应该设计为函数,目前的普遍共识与2015年的建议基本一致,但实践中有更细致的考量。

核心原则:如果一个方法不修改接收者(receiver),且其行为类似于纯函数,那么将其定义为函数通常更符合Go的惯用法。这样做的好处是:

  1. 明确表明该操作不会修改接收者
  2. 便于测试(无需创建对象实例)
  3. 更清晰的API设计

示例对比

// 方法形式 - 可能暗示会修改对象
type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// 函数形式 - 明确不会修改对象
func Area(r Rectangle) float64 {
    return r.Width * r.Height
}

当前实践中的考量因素

  1. 接口实现:如果方法需要满足接口,则必须使用方法形式

    type AreaCalculator interface {
        Area() float64
    }
    
    // 必须使用方法
    func (r Rectangle) Area() float64 {
        return r.Width * r.Height
    }
    
  2. 方法链:如果支持链式调用,使用方法更合适

    type Builder struct {
        values []string
    }
    
    func (b Builder) WithPrefix(prefix string) Builder {
        // 返回新对象,不修改原接收者
        return Builder{
            values: append([]string{prefix}, b.values...),
        }
    }
    
  3. 一致性:如果类型已有其他方法,为保持API一致,可能仍使用方法

    type Vector struct {
        X, Y float64
    }
    
    func (v Vector) Add(other Vector) Vector {
        return Vector{v.X + other.X, v.Y + other.Y}
    }
    
    func (v Vector) Scale(factor float64) Vector {
        return Vector{v.X * factor, v.Y * factor}
    }
    
  4. 性能考量:对于大对象,使用指针接收者避免复制,即使不修改对象

    func (r *Rectangle) Area() float64 {
        return r.Width * r.Height
    }
    

2021年后的趋势

  • 社区更倾向于明确性,函数形式逐渐增多
  • 标准库中有很多示例:strings.TrimSpace(s) vs s.TrimSpace()
  • 对于值类型的小对象,两种方式都可接受
  • 文档和API一致性成为更重要的决定因素

建议:如果不确定,优先考虑函数形式,除非有明确的理由需要使用方法(如接口实现、方法链、保持API一致性)。

回到顶部