【问题标题】:Prevent missing fields in struct initialization防止结构初始化中缺少字段
【发布时间】:2019-02-05 18:15:19
【问题描述】:

考虑这个例子。假设我有这个在我的代码库中无处不在的对象:

type Person struct {
    Name string
    Age  int
    [some other fields]
}

在代码库的深处,我还有一些代码可以创建一个新的Person 结构。也许它类似于以下实用函数(请注意,这只是创建Person 的某些函数的示例——我的问题不是专门询问复制函数):

func copyPerson(origPerson Person) *Person {
    copy := Person{
        Name: origPerson.Name,
        Age:  origPerson.Age,
        [some other fields]
    }
    return &copy
}

另一个开发人员出现并在Person 结构中添加了一个新字段Gender。然而,由于copyPerson 函数位于一段遥远的代码中,他们忘记更新copyPerson。由于在创建结构时省略参数,golang 不会抛出任何警告或错误,因此代码将编译并且看起来工作正常;唯一的区别是copyPerson 方法现在将无法复制Gender 结构,并且copyPerson 的结果将Gender 替换为nil 值(例如空字符串)。

防止这种情况发生的最佳方法是什么?有没有办法让 golang 在特定的结构初始化中强制不丢失参数?是否有可以检测此类潜在错误的 linter?

【问题讨论】:

  • 最好的方法是编写自定义 linter。
  • 在单元测试期间很容易发现的假设问题,因此无需担心。
  • @Volker 你的评论让我想起了 Beeblebrox 的 Peril Sensitive Sunglasses。 ???不过不是想让你难过。

标签: go struct initialization composite-literals


【解决方案1】:

我解决这个问题的方法是只使用NewPerson(params) 而不是导出这个人。这使得获取person 实例的唯一方法是通过您的New 方法。

package person

// Struct is not exported
type person struct {
    Name string
    Age  int
    Gender bool
}

// We are forced to call the constructor to get an instance of person
func New(name string, age int, gender bool) person {
    return person{name, age, gender}
}

这迫使每个人都从同一个地方获取实例。当您添加一个字段时,您可以将其添加到函数定义中,然后在它们构建新实例的任何地方都会出现编译时错误,因此您可以轻松找到它们并修复它们。

【讨论】:

  • 只有通过 getter/setter 方法才能访问的字段是 Go 语言的惯用做法。
  • @Adrian 并不是我所展示的重点。使用接口是完全惯用的,如果您需要访问字段,您只需返回结构而不是接口 - 未导出结构的导出字段仍然可以访问。
  • 使用这种方法,编写接受person结构作为参数的函数会变得很冗长,其他包中的消费者必须将每个字段自己定义为接口。
【解决方案2】:

首先,您的copyPerson() 函数名不副实。它复制Person 的一些 字段,但不是(必然)全部。它应该被命名为copySomeFieldsOfPerson()。

要复制一个完整的结构值,只需分配结构值。如果你有一个接收非指针Person的函数,那已经是一个副本,所以只需返回它的地址:

func copyPerson(p Person) *Person {
    return &p
}

就是这样,这将复制Person的所有当前和未来字段。

现在可能存在字段是指针或类似标题的值(如切片)的情况,应该从原始字段(更准确地说是从指向的对象)“分离”,在这种情况下,您确实需要手动调整,例如

type Person struct {
    Name string
    Age  int
    Data []byte
}

func copyPerson(p Person) *Person {
    p2 := p
    p2.Data = append(p2.Data, p.Data...)
    return &p2
}

或者另一种解决方案,它不会复制p,但仍会分离Person.Data:

func copyPerson(p Person) *Person {
    var data []byte
    p.Data = append(data, p.Data...)
    return &p
}

当然,如果有人添加了一个也需要手动处理的字段,这将无济于事。

你也可以使用无键文字,像这样:

func copyPerson(p Person) *Person {
    return &Person{
        p.Name,
        p.Age,
    }
}

如果有人将新字段添加到Person,这将导致编译时错误,因为未键入的复合结构文字必须列出所有字段。同样,如果有人更改了可以将新字段分配给旧字段的字段(例如,有人将两个具有相同类型的字段彼此相邻交换),这也无济于事,也不鼓励使用无键文字。

包所有者最好在Person 类型定义旁边提供一个复制构造函数。因此,如果有人更改了Person,他/她应该负责保持CopyPerson() 的正常运行。正如其他人所提到的,如果CopyPerson() 名不副实,你应该已经有单元测试应该失败。

最好的可行选择?

如果您不能将 CopyPerson() 放在 Person 类型旁边并让其作者维护它,请继续进行结构值复制和手动处理指针和标题类字段。

您可以创建一个person2 类型,它是Person 类型的“快照”。如果原始Person 类型发生更改,请使用空白全局变量接收编译时警报,在这种情况下,copyPerson() 包含的源文件将拒绝编译,因此您会知道它需要调整。

可以这样做:

type person2 struct {
    Name string
    Age  int
}

var _ = Person(person2{})

如果Person 和person2 的字段不匹配,则空白变量声明将无法编译。

上述编译时检查的一个变体可能是使用 typed-nil 指针:

var _ = (*Person)((*person2)(nil))

【讨论】:

    【解决方案3】:

    我不知道强制执行此操作的语言规则。

    但如果您愿意,您可以为Go vet 编写自定义检查器。 Here's a recent post talking about that.


    也就是说,我会重新考虑这里的设计。如果Person 结构在您的代码库中如此重要,请集中创建和复制它,这样“遥远的地方”就不会只是创建和移动Persons。重构您的代码,以便仅使用一个构造函数来构建Persons(可能类似于person.New 返回person.Person),然后您将能够集中控制其字段的初始化方式。

    【讨论】:

      【解决方案4】:

      惯用的方法是根本不这样做,而是make the zero value useful。复制函数的示例并没有真正的意义,因为它完全没有必要——你可以说:

      copy := new(Person)
      *copy = *origPerson
      

      不需要专门的功能,也不需要保持最新的字段列表。如果你想要像NewPerson 这样的新实例的构造函数,只需编写一个并使用它作为理所当然的事情。 Linter 在某些方面非常有用,但没有什么比广为人知的最佳实践和同行代码审查更好的了。

      【讨论】:

        【解决方案5】:

        我能想出的最好的解决方案(它不是很好)是定义一个新的结构 tempPerson 与 Person 结构相同,并将它放在任何初始化新 Person 结构的代码附近,并更改初始化Person 的代码,以便将其初始化为tempPerson,然后将其转换为Person。像这样:

        type tempPerson struct {
            Name string
            Age  int
            [some other fields]
        }
        
        func copyPerson(origPerson Person) *Person {
            tempCopy := tempPerson{
                Name: orig.Name,
                Age:  orig.Age,
                [some other fields]
            }
            copy := (Person)(tempCopy)
            return &copy
        }
        

        这样,如果将另一个字段 Gender 添加到 Person 而不是添加到 tempPerson,则代码将在编译时失败。大概开发人员会看到错误,编辑tempPerson以匹配他们对Person的更改,并在这样做时注意附近使用tempPerson的代码并认识到他们应该编辑该代码以处理Gender字段也是如此。

        我不喜欢这个解决方案,因为它涉及在我们初始化Person 结构并希望获得这种安全性的任何地方复制和粘贴结构定义。有没有更好的办法?

        【讨论】:

        • 这有一个好主意,但是每次调用copyPerson() 时,都不需要构造tempPerson 的值并将其转换并分配给Person。毕竟,您只需要知道结构类型(它们的字段)是否匹配,可以在编译时执行哪种检查。见my answer how to do that。
        【解决方案6】:

        方法 1 添加类似拷贝构造函数的东西:

        type Person struct {
            Name string
            Age  int
        }
        
        func CopyPerson(name string, age int)(*Person, error){
            // check params passed if needed
            return &Person{Name: name, Age: age}, nil
        }
        
        
        p := CopyPerson(p1.Name, p1.age) // force all fields to be passed
        

        方法 2:(不确定是否可行)

        这可以在使用反射的测试中涵盖吗?
        如果我们比较原始结构中初始化的字段数(初始化所有字段的值与默认值不同)和复制函数返回的副本中的字段。

        【讨论】:

          【解决方案7】:

          我会这样做:

          func copyPerson(origPerson Person) *Person { 
              newPerson := origPerson
          
              //proof that 'newPerson' points to a new person object
              newPerson.name = "new name"
              return &newPerson
          }
          

          Go Playground

          【讨论】:

          • 我不认为他是在寻求一种更好的方法来复制一个人,他是在询问如何处理将字段添加到结构中时,以确保创建实例的任何地方都设置值并且它不只是获取默认值。
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多