Golang中CRUD方法应该返回值还是指针结构体?

Golang中CRUD方法应该返回值还是指针结构体? 我知道这个问题已经被广泛讨论过,但我在网上找到的答案大多非常模糊。共识似乎是对于小型结构体使用值返回,对于大型结构体使用指针返回。然而,混合使用这两种风格会严重破坏设计的一致性,所以如果必须坚持使用一种类型,那会是哪一种呢?

考虑这样一种情况:应用程序是一个Web API,主要负责对可能有多达20~30列(结构体字段)的数据库表进行CRUD操作。

5 回复

感谢您的回答,这让我今天很开心 :slight_smile:

更多关于Golang中CRUD方法应该返回值还是指针结构体?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


说实话,我总是返回指针。 这允许我在出现错误时不必初始化结构体。

例如,一个 GET 操作可能如下所示:

func (r *Repository) Get(id int) (*MyStruct, error) {
    var ret *MyStruct

    // 检查条件
    if <conditions not met> {
        return nil, NotFound
    }
    ret = ....
    ret, nil
}

对于 CreateUpdate 等操作也是如此。

代码风格

我通常总是返回结构体,除非有明确理由使用指针。指针会带来额外的麻烦:空指针恐慌、被其他函数/协程修改,以及额外的分配/垃圾回收压力。

返回普通结构体的函数结果永远不需要考虑空值检查。当函数返回指针时,我必须阅读文档(并希望它是最新的)以确保没有值和错误同时为 nil 的情况。

编写函数本身两种方式都很直接——你不需要手动初始化结构体——如果你声明了该类型的变量,它会自动初始化。但如果你使用指针,就会遇到额外的陷阱——你必须考虑是否需要在将其传递给 json.Unmarshal 或类似接受指针的函数之前初始化它。

性能

首先——如果你没有在紧密循环中或同时成千上万次地调用这些函数,结构体与指针的性能差异可能根本无关紧要——只需选择更清晰、更符合语言习惯的方式即可(不要过早优化)。

但如果你想知道哪种性能更好——很可能几乎总是普通结构体。如果函数相对较小,编译器有很大可能会将其内联——这样你的结构体就根本不会被复制,而是直接写入栈中。即使你的结构体被复制了,在栈上的复制也很可能比在堆上的内存分配更快,因为内存局部性更好且没有垃圾回收开销。

这就剩下非常大的™结构体的情况,因为它们复制起来可能很昂贵,并且如果编译器认为它们对于栈来说太大了,它们可能会逃逸到堆上——在这种情况下,你将会有一次外部的分配和结构体复制,这比使用指针进行单次分配要慢。你可以通过运行 go build -gcflags=-m ... 来检查一个值是否逃逸到堆上。 你的结构体大到足以触发这种逃逸(达到数百字节)的可能性很低,因为这仅包括结构体中的直接值和数组。你的结构体中的所有映射和切片无论如何都是引用,不会计入结构体本身的大小。

总结

所以在大多数情况下,使用指针实际上会得到更差的性能,并且会使代码在阅读和编写时稍微更复杂一些。

大多数情况下,这并不重要。这就是为什么没有达成共识。为什么它不重要呢?

对于那些旨在表示逻辑的类型(如 AuthServiceProductRepositoryProductListingHandlerLogger)的结构体(或任何类型),它们的字段通常不包含任何状态,只包含依赖项。以下是一个名为 AuthService 的结构体可能的样子示例:

type CredentialAccessor interface {
    GetUserByEmail(ctx context.Context, email string) (User, error)
}

type PasswordChecker interface {
    HashPassword(password string) string
    VerifyPassword(hashedPassword, password string) error
}

type AuthService struct{
    credentialAccessor CredentialAccessor
    passwordChecker    PasswordChecker
}

func NewAuthService(
    credentialAccessor CredentialAccessor, 
    passwordChecker    PasswordChecker,
) AuthService { // 或者你也可以返回 *AuthService
   ...
}

如你所见,AuthService 中的所有字段都不管理任何状态。credentialAccessorpasswordChecker 的值永远不会改变。它们很可能在程序首次运行时被注入,并且会一直保持不变。 因此,你可以返回 AuthService(而不是 *AuthService)并期望它能正常运行。如果你将其改为 *AuthService,它也能正确运行。是否存在性能开销?也许有,如果你返回 *AuthService,可能会触发堆分配,但这并不重要。因为最终,你可能还是会把 AuthService 转换为接口,从而触发分配。AuthService 中的所有字段也都已经包装为接口,这可能已经触发了分配。除此之外,这种分配非常小,而且只发生一次。它小到你甚至不会注意到。

如果你需要维护状态,返回指针是明智的,因为你可能不希望出现状态分裂的情况,即你传递结构体、修改它,但调用者却没有得到修改。

如果你的结构体表示数据,如 UserResponse,那就是另一回事了。你可能希望结构体是不可变的。在这种情况下,返回指针可能是不好的。此外,如果它表示数据,它会被频繁分配,返回指针可能会导致结构体逃逸到堆上,这对性能可能不利。

现在,在上述段落中,我讨论了正确性和性能开销。还有关于风格的争论。但风格是非常主观的。我相信你也有自己的风格。在这种情况下,只需遵循你自己的风格,因为这对其他人并不重要。如果你在团队中工作,只需就一种风格达成一致,并遵循它。

在Go语言中,关于CRUD方法返回值结构体还是指针结构体的讨论,确实需要根据具体场景权衡。对于你描述的Web API场景,处理20-30列的数据表,我推荐统一使用指针结构体。以下是详细分析和示例:

主要理由

  1. 性能考虑:虽然20-30个字段的结构体不算极大,但每个字段可能包含字符串、切片等引用类型,实际内存占用可能比预期大。指针传递避免了大结构体的值拷贝开销。
  2. 一致性:混合使用会导致API不一致,增加维护成本。指针方案能统一处理所有情况。
  3. 空值处理:指针可以明确表示“空值”(nil),而值类型需要额外字段(如IsValid)或零值判断。
  4. 修改便利性:如果后续需要链式调用或修改返回对象,指针更自然。

示例代码

假设有一个用户表对应的结构体:

type User struct {
    ID        int64
    Name      string
    Email     string
    Age       int
    CreatedAt time.Time
    UpdatedAt time.Time
    // ... 其他20多个字段
}

// 推荐:返回指针
func GetUserByID(id int64) (*User, error) {
    user := &User{}
    err := db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(
        &user.ID, &user.Name, &user.Email, &user.Age,
        &user.CreatedAt, &user.UpdatedAt, // ... 其他字段
    )
    if err != nil {
        return nil, err
    }
    return user, nil
}

// 创建用户也返回指针
func CreateUser(user *User) (*User, error) {
    result, err := db.Exec("INSERT INTO users (...) VALUES (...)",
        user.Name, user.Email, user.Age, // ... 其他字段
    )
    if err != nil {
        return nil, err
    }
    user.ID, _ = result.LastInsertId()
    return user, nil
}

注意事项

  1. 并发安全:返回指针时,如果多个goroutine可能修改同一对象,需要同步机制或返回深拷贝。
  2. 空指针检查:调用方必须处理nil情况。
  3. 内存管理:指针可能延长对象生命周期,影响GC效率,但在Web API中通常不是问题。

对比值返回

// 值返回版本(不推荐在此场景使用)
func GetUserByIDValue(id int64) (User, error) {
    var user User
    // ... 查询逻辑
    return user, nil // 这里会发生整个结构体的拷贝
}

结论

对于你的场景,坚持使用指针结构体是更合理的选择。这保证了:

  • 性能可预测性
  • 代码一致性
  • 与数据库操作的自然对接(Scan方法通常需要指针)
  • 未来扩展性(即使字段增加也不用改变签名)

这种选择已被许多大型Go项目(如Kubernetes相关组件)验证,适合数据密集型的Web API开发。

回到顶部