我们在Go中处理接口


在过去的几个月中,我一直在进行一项研究,询问人们在Go中难于理解的内容。 而且我注意到答案中经常提到接口的概念。 Go是我使用的第一种界面语言,我记得那时这个概念似乎很混乱。 在本指南中,我想这样做:

  1. 用人类语言解释什么是接口。
  2. 解释它们的用处以及如何在代码中使用它们。
  3. 讨论interface{}什么(一个空接口)。
  4. 并逐步介绍一些在标准库中可以找到的有用的接口类型。

那么什么是接口?


Go中的接口类型是一种定义 。 它定义并描述了某些其他类型应具有的特定方法。

标准库中的接口类型之一是fmt.Stringer接口:

 type Stringer interface { String() string } 

我们说,如果某个“东西”具有带有特定签名字符串值String()的方法,则满足该接口 (或实现此接口 )的东西。

例如, Book类型满足接口,因为它具有String()字符串方法:

 type Book struct { Title string Author string } func (b Book) String() string { return fmt.Sprintf("Book: %s - %s", b.Title, b.Author) } 

Book类型或功能无关紧要。 重要的是它具有一个称为String()的方法,该方法返回字符串值。

这是另一个例子。 Count类型也满足 fmt.Stringer 接口,因为它具有具有相同签名字符串值String()

 type Count int func (c Count) String() string { return strconv.Itoa(int(c)) } 

在这里必须了解,我们有两种不同类型的BookCount ,它们的行为不同。 但是它们都满足于fmt.Stringer接口这一事实使它们团结在一起。

您可以从另一侧看它。 如果知道对象满足fmt.Stringer接口,则可以假定它具有可以调用的带有签名字符串值String()的方法。

现在最重要的是。

当您在Go(变量,函数参数或结构字段的声明)中看到具有接口类型的声明时,可以使用任何类型的对象,只要它满足该接口。

假设我们有一个函数:

 func WriteLog(s fmt.Stringer) { log.Println(s.String()) } 

由于WriteLog()在参数fmt.Stringer使用接口类型fmt.Stringer ,因此我们可以传递任何满足fmt.Stringer接口的对象。 例如,我们可以传递之前在WriteLog()方法中创建的BookCount类型,代码可以正常工作。

另外,由于传递的对象满足fmt.Stringer接口,因此我们知道它具有String()方法,可以通过WriteLog()函数安全地调用该方法。

让我们将其汇总为一个示例,以演示接口的功能。

 package main import ( "fmt" "strconv" "log" ) //   Book,    fmt.Stringer. type Book struct { Title string Author string } func (b Book) String() string { return fmt.Sprintf("Book: %s - %s", b.Title, b.Author) } //   Count,    fmt.Stringer. type Count int func (c Count) String() string { return strconv.Itoa(int(c)) } //   WriteLog(),    , //   fmt.Stringer   . func WriteLog(s fmt.Stringer) { log.Println(s.String()) } func main() { //   Book    WriteLog(). book := Book{"Alice in Wonderland", "Lewis Carrol"} WriteLog(book) //   Count    WriteLog(). count := Count(3) WriteLog(count) } 

太棒了 在主函数中,我们创建了不同类型的BookCount ,但是将它们传递给了相同的 WriteLog()函数。 她调用了适当的String()函数,并将结果写入日志。

如果执行代码 ,您将得到类似的结果:

 2009/11/10 23:00:00 Book: Alice in Wonderland - Lewis Carrol 2009/11/10 23:00:00 3 

我们将不对此进行详细介绍。 要记住的主要事情:在WriteLog()函数的声明中使用接口类型,我们使函数对接收到的对象的类型无动于衷(或灵活)。 重要的是他有什么方法

什么是有用的界面?


您可以在Go中开始使用界面的原因有很多。 以我的经验,最重要的是:

  1. 接口有助于减少重复,即减少样板代码的数量。
  2. 它们使在单元测试中代替实际对象更容易使用存根。
  3. 作为一种架构工具,界面可帮助解开部分代码库。

让我们仔细看看这些使用接口的方式。

减少样板代码量


假设我们有一个包含某种客户数据的Customer结构。 在代码的一部分中,我们希望将此信息写入bytes.Buffer ,在另一部分中,我们要将客户端数据写入磁盘上的os.File 。 但是,在两种情况下,我们都希望首先将ustomer结构序列化为JSON。

在这种情况下,我们可以使用Go接口减少样板代码的数量。

Go具有io.Writer接口类型:

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

我们可以利用bytes.Bufferos.File类型满足此接口的事实,因为它们分别具有bytes.Buffer.Write()os.File.Write()方法。

简单实施:

 package main import ( "encoding/json" "io" "log" "os" ) //   Customer. type Customer struct { Name string Age int } //   WriteJSON,   io.Writer   . //    ustomer  JSON,     // ,     Write()  io.Writer. func (c *Customer) WriteJSON(w io.Writer) error { js, err := json.Marshal(c) if err != nil { return err } _, err = w.Write(js) return err } func main() { //   Customer. c := &Customer{Name: "Alice", Age: 21} //    Buffer    WriteJSON var buf bytes.Buffer err := c.WriteJSON(buf) if err != nil { log.Fatal(err) } //   . f, err := os.Create("/tmp/customer") if err != nil { log.Fatal(err) } defer f.Close() err = c.WriteJSON(f) if err != nil { log.Fatal(err) } } 

当然,这只是一个虚构的示例(我们可以以不同的方式构造代码以获得相同的结果)。 但这很好地说明了使用接口的优点:我们可以创建一次Customer.WriteJSON()方法,并在每次需要写入满足io.Writer接口的内容时调用它。

但是,如果您不熟悉 Go,您将有两个问题:“ 我怎么知道io.Writer接口是否存在? 以及您如何预先知道他对bytes.Bufferos.File感到满意?

恐怕没有简单的解决方案。 您只需要获取经验,熟悉标准库中的接口和不同类型即可。 这将有助于阅读该库的文档并查看其他人的代码。 为了快速参考,我在文章结尾添加了最有用的接口类型。

但是,即使您不使用标准库中的接口,也不会阻止您创建和使用自己的接口类型 。 我们将在下面讨论。

单元测试和存根


要了解接口如何在单元测试中提供帮助,我们来看一个更复杂的示例。

假设您有一家商店,并在PostgreSQL中存储有关销售和客户数量的信息。 您想编写一个代码,计算最后一天的销售份额(每个客户的特定销售数量),四舍五入到小数点后两位。

最小的实现如下所示:

 // : main.go package main import ( "fmt" "log" "time" "database/sql" _ "github.com/lib/pq" ) type ShopDB struct { *sql.DB } func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count) return count, err } func (sdb *ShopDB) CountSales(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count) return count, err } func main() { db, err := sql.Open("postgres", "postgres://user:pass@localhost/db") if err != nil { log.Fatal(err) } defer db.Close() shopDB := &ShopDB{db} sr, err := calculateSalesRate(shopDB) if err != nil { log.Fatal(err) } fmt.Printf(sr) } func calculateSalesRate(sdb *ShopDB) (string, error) { since := time.Now().Sub(24 * time.Hour) sales, err := sdb.CountSales(since) if err != nil { return "", err } customers, err := sdb.CountCustomers(since) if err != nil { return "", err } rate := float64(sales) / float64(customers) return fmt.Sprintf("%.2f", rate), nil } 

现在,我们要为calculateSalesRate()函数创建一个单元测试,以验证计算是否正确。

现在有问题了。 我们将需要配置PostgreSQL的测试实例,以及创建和删除脚本以用假数据填充数据库。 如果我们真的想测试我们的计算,我们还有很多工作要做。

接口可以解救!

我们将创建自己的接口类型,以描述CountSales()CountCustomers()方法, calculateSalesRate()函数所依赖的接口类型。 然后更新签名calculateSalesRate()以将此接口类型用作参数,而不是指定的*ShopDB类型。

像这样:

 // : main.go package main import ( "fmt" "log" "time" "database/sql" _ "github.com/lib/pq" ) //    ShopModel.     //     ,     //  -,     . type ShopModel interface { CountCustomers(time.Time) (int, error) CountSales(time.Time) (int, error) } //  ShopDB    ShopModel,   //       -- CountCustomers()  CountSales(). type ShopDB struct { *sql.DB } func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count) return count, err } func (sdb *ShopDB) CountSales(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count) return count, err } func main() { db, err := sql.Open("postgres", "postgres://user:pass@localhost/db") if err != nil { log.Fatal(err) } defer db.Close() shopDB := &ShopDB{db} sr := calculateSalesRate(shopDB) fmt.Printf(sr) } //       ShopModel    //    *ShopDB. func calculateSalesRate(sm ShopModel) string { since := time.Now().Sub(24 * time.Hour) sales, err := sm.CountSales(since) if err != nil { return "", err } customers, err := sm.CountCustomers(since) if err != nil { return "", err } rate := float64(sales) / float64(customers) return fmt.Sprintf("%.2f", rate), nil } 

完成此操作后,我们将只创建一个满足ShopModel接口的存根。 然后,您可以在函数calculateSalesRate()对数学逻辑的正确操作进行单元测试时使用它。 像这样:

 // : main_test.go package main import ( "testing" ) type MockShopDB struct{} func (m *MockShopDB) CountCustomers() (int, error) { return 1000, nil } func (m *MockShopDB) CountSales() (int, error) { return 333, nil } func TestCalculateSalesRate(t *testing.T) { //  . m := &MockShopDB{} //     calculateSalesRate(). sr := calculateSalesRate(m) // ,        //   . exp := "0.33" if sr != exp { t.Fatalf("got %v; expected %v", sr, exp) } } 

现在运行测试,一切正常。

应用架构


在前面的示例中,我们看到了如何使用接口将代码的某些部分与使用特定类型分离。 例如,只要满足ShopModel接口,那么calculateSalesRate()函数对您传递的内容ShopModel

您可以扩展这个想法,并在大型项目中创建整个“统一”级别。
假设您正在创建一个与数据库交互的Web应用程序。 如果您创建一个描述与数据库交互的特定方法的接口,则可以通过HTTP处理程序而不是特定类型来引用该接口。 由于HTTP处理程序仅引用该接口,因此这将有助于彼此断开HTTP级别和与数据库的交互级别的链接。 独立处理关卡将更加容易,并且将来您将能够替换某些关卡而不会影响其他关卡的工作。

在之前的一篇文章中介绍了这种模式, 其中有更多详细信息和实际示例。

什么是空接口?


如果您已经在Go上编程了一段时间,那么您可能会遇到一个空的接口类型 interface{} 。 我将尝试解释它是什么。 在本文开头,我写道:

Go中的接口类型是一种定义 。 它定义并描述了某些其他类型应具有的特定方法。

空的接口类型不能描述方法 。 他没有规矩。 因此,任何对象都满足一个空接口。

本质上,空接口类型interface{}是一种玩笑。 如果在声明(变量,函数参数或结构字段)中遇到它,则可以使用任何类型的对象。

考虑以下代码:

 package main import "fmt" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 fmt.Printf("%+v", person) } 

在这里,我们将地图初始化为person ,它使用字符串类型的键和空接口类型的interface{}作为值。 我们为映射值分配了三种不同类型(字符串,整数和float32),这没有问题。 由于任何类型的对象都满足空接口,因此代码可以很好地工作。

您可以在此处运行此代码 ,您将看到类似的结果:

 map[age:21 height:167.64 name:Alice] 

在从地图中提取和使用值时,请务必牢记这一点。 假设您要获取age值并将其增加1。如果您编写类似的代码,则它将无法编译:

 package main import "log" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 person["age"] = person["age"] + 1 fmt.Printf("%+v", person) } 

您将收到一条错误消息:

 invalid operation: person["age"] + 1 (mismatched types interface {} and int) 

原因是存储在map中的值采用interface{}类型,并失去了其原始的基本int类型。 并且由于该值不再是整数,因此我们无法对其加1。

为了解决这个问题,您需要再次使该值成为整数,然后才可以使用它:

 package main import "log" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 age, ok := person["age"].(int) if !ok { log.Fatal("could not assert value to int") return } person["age"] = age + 1 log.Printf("%+v", person) } 

如果运行this ,一切将按预期工作:

 2009/11/10 23:00:00 map[age:22 height:167.64 name:Alice] 

那么什么时候应该使用空接口类型呢?

也许不是太经常 。 如果您遇到这种情况,请停下来考虑interface{}使用interface{}是否正确。 作为一般建议,我可以说,使用特定类型(即非空接口类型)将更加清晰,安全和高效。 在上面的示例中,最好使用适当类型的字段定义Person结构:

 type Person struct { Name string Age int Height float32 } 

另一方面,当您需要访问和使用不可预测或用户定义的类型时,空接口非常有用。 出于某种原因,此类接口在标准库的不同位置使用,例如gob.Encodefmt.Printtemplate.Execute函数。

有用的接口类型


这是标准库中最需要和最有用的接口类型的简短列表。 如果您还不熟悉它们,则建议阅读相关文档。


标准库的详细列表也可以在此处找到

Source: https://habr.com/ru/post/zh-CN463063/


All Articles