跳转到主要内容

介绍


在 Go 1.18 中,该语言引入了一个名为泛型类型(通常简称为泛型)的新特性,该特性在 Go 开发人员的愿望清单上已有一段时间了。在编程中,泛型类型是可以与多种其他类型结合使用的类型。通常在 Go 中,如果您希望能够对同一个变量使用两种不同的类型,则需要使用特定的接口,例如 io.Reader,或者使用允许使用任何值的 interface{} .但是,使用 interface{} 可能会使处理这些类型变得困难,因为您需要在其他几种潜在类型之间进行转换才能与它们交互。使用泛型类型可以让你直接与你的类型进行交互,从而使代码更清晰、更易于阅读。

在本教程中,您将创建一个与一副纸牌交互的程序。您将首先创建一个使用 interface{} 与卡片交互的卡片组,然后您将更新它以使用泛型类型。在这些更新之后,您将使用泛型将第二种类型的卡片添加到您的套牌中,然后您将更新您的套牌以将其泛型类型限制为仅支持卡片类型。最后,您将创建一个使用您的卡片并支持泛型类型的函数。

先决条件


要遵循本教程,您将需要:

  • 已安装 Go 版本 1.18 或更高版本。要进行设置,请按照您的操作系统的如何安装 Go 教程进行操作。
  • 对 Go 语言有扎实的了解,例如变量、函数、结构类型、for 循环和切片。如果您想了解更多关于这些概念的信息,我们的如何在 Go 中编码系列有许多涵盖这些以及更多内容的教程。

没有泛型的 Go 集合


Go 的一个强大功能是它能够使用接口灵活地表示多种类型。许多用 Go 编写的代码仅使用提供的功能接口就可以很好地工作。这就是为什么 Go 在不支持泛型的情况下存在这么久的原因之一。

在本教程中,您将创建一个围棋程序,模拟从一副纸牌中随机获取一张扑克牌。在本节中,您将使用接口{} 来允许 Deck 与任何类型的卡片进行交互。在本教程的后面,您将更新您的程序以使用泛型,以便您可以更好地理解它们之间的差异,并识别何时一个比另一个更好。

编程语言中的类型系统通常可以分为两类:类型和类型检查。一种语言可以使用强类型或弱类型,以及静态或动态类型检查。有些语言混合使用了这些,但 Go 非常适合强类型和静态检查的语言。强类型意味着 Go 确保变量中的值与变量的类型匹配,因此例如,您不能将 int 值存储在字符串变量中。作为一个静态检查类型系统,Go 的编译器会在编译程序而不是在程序运行时检查这些类型规则。

使用像 Go 这样的强类型、静态检查的语言的一个好处是,编译器可以让您在程序发布之前了解任何潜在的错误,从而避免某些“无效类型”运行时错误。不过,这确实给 Go 程序增加了一个限制,因为在编译程序之前,您必须知道要使用的类型。处理此问题的一种方法是使用 interface{} 类型。 interface{} 类型适用于任何值的原因是因为它没有为接口定义任何必需的方法(由空 {} 表示),因此任何类型都与接口匹配。

要开始使用 interface{} 创建程序来表示您的卡片,您需要一个目录来保存程序的目录。在本教程中,您将使用一个名为 projects 的目录。

首先,创建项目目录并导航到它:

mkdir projects
cd projects


接下来,为您的项目创建目录并导航到它。在这种情况下,使用目录泛型:

mkdir generics
cd generics


在 generics 目录中,使用 nano 或您喜欢的编辑器打开 main.go 文件:

nano main.go


在 main.go 文件中,首先添加你的包声明并导入你需要的包:

package main

import (
    "fmt"
    "math/rand"
    "os"
    "time"
)

package main 声明告诉 Go 将您的程序编译为二进制文件,以便您可以直接运行它,而 import 语句告诉 Go 您将在以后的代码中使用哪些包。

现在,定义您的 PlayingCard 类型及其关联的函数和方法:

main.go

...

type PlayingCard struct {
    Suit string
    Rank string
}

func NewPlayingCard(suit string, card string) *PlayingCard {
    return &PlayingCard{Suit: suit, Rank: card}
}

func (pc *PlayingCard) String() string {
    return fmt.Sprintf("%s of %s", pc.Rank, pc.Suit)
}

 

在此代码段中,您定义了一个名为 PlayingCard 的结构,其属性为 Suit 和 Rank,以表示一副 52 张扑克牌中的牌。花色将是方块、红心、梅花或黑桃之一,等级将为 A、2、3,以此类推到 K。

您还定义了一个 NewPlayingCard 函数作为 PlayingCard 结构的构造函数,以及一个 String 方法,它将使用 fmt.Sprintf 返回卡片的点数和花色。

接下来,使用 AddCard 和 RandomCard 方法创建您的 Deck 类型,以及 NewPlayingCardDeck 函数来创建一个装满所有 52 张扑克牌的 *Deck:

main.go

...

type Deck struct {
    cards []interface{}
}

func NewPlayingCardDeck() *Deck {
    suits := []string{"Diamonds", "Hearts", "Clubs", "Spades"}
    ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}

    deck := &Deck{}
    for _, suit := range suits {
        for _, rank := range ranks {
            deck.AddCard(NewPlayingCard(suit, rank))
        }
    }
    return deck
}

func (d *Deck) AddCard(card interface{}) {
    d.cards = append(d.cards, card)
}

func (d *Deck) RandomCard() interface{} {
    r := rand.New(rand.NewSource(time.Now().UnixNano()))

    cardIdx := r.Intn(len(d.cards))
    return d.cards[cardIdx]
}

在上面定义的 Deck 中,您创建了一个名为 cards 的字段来保存一张卡片。由于您希望套牌能够容纳多种不同类型的卡片,但您不能将其定义为 []*PlayingCard。您将其定义为 []interface{},因此它可以容纳您将来可能创建的任何类型的卡。除了 Deck 上的 []interface{} 字段外,您还创建了一个 AddCard 方法,该方法接受相同的 interface{} 类型以将卡片附加到 Deck 的卡片字段。

您还创建了一个 RandomCard 方法,该方法将从 Deck 的卡片切片中返回一张随机卡片。此方法使用 math/rand 包生成介于 0 和卡片切片中卡片数量之间的随机数。 rand.New 行使用当前时间作为随机源创建一个新的随机数生成器;否则,随机数可能每次都相同。 r.Intn(len(d.cards)) 行使用随机数生成器生成介于 0 和提供的数字之间的 int 值。由于 Intn 方法不包含数字范围内的参数值,所以不需要从长度减去 1 来说明从 0 开始。最后,RandomCard 返回随机数索引处的卡片值。

警告:请注意您在程序中使用的随机数生成器。 math/rand 包不是加密安全的,不应该用于安全敏感的程序。然而,crypto.rand 包确实提供了一个可用于这些目的的随机数生成器。

最后,NewPlayingCardDeck 函数返回一个 *Deck 值,其中填充了扑克牌组中的所有牌。您使用两个切片,一个包含所有可用的套装,一个包含所有可用的等级,然后循环每个值以为每个组合创建一个新的 *PlayingCard,然后使用 AddCard 将其添加到牌组中。一旦生成了牌组的卡片,就会返回该值。

现在您已经设置了 Deck 和 PlayingCard,您可以创建 main 函数来使用它们来抽牌:

main.go

...

func main() {
    deck := NewPlayingCardDeck()

    fmt.Printf("--- drawing playing card ---\n")
    card := deck.RandomCard()
    fmt.Printf("drew card: %s\n", card)

    playingCard, ok := card.(*PlayingCard)
    if !ok {
        fmt.Printf("card received wasn't a playing card!")
        os.Exit(1)
    }
    fmt.Printf("card suit: %s\n", playingCard.Suit)
    fmt.Printf("card rank: %s\n", playingCard.Rank)
}

在 main 函数中,您首先使用 NewPlayingCardDeck 函数创建一副新的扑克牌并将其分配给甲板变量。然后,您使用 fmt.Printf 打印您正在抽卡并使用卡组的 RandomCard 方法从卡组中获取一张随机卡。之后,您再次使用 fmt.Printf 打印您从卡组中抽出的卡片。

接下来,由于卡片变量的类型是 interface{},因此您需要使用类型断言来获取对卡片的引用作为其原始 *PlayingCard 类型。如果 card 变量中的类型不是 *PlayingCard 类型,它应该给出你的程序现在是如何编写的,ok 的值将是 false 并且你的程序将使用 fmt.Printf 打印一条错误消息并退出使用 os.Exit 的错误代码为 1。如果它是 *PlayingCard 类型,则您可以使用 fmt.Printf 打印出 playCard 的 Suit 和 Rank 值。

保存所有更改后,您可以使用 go run 和 main.go(要运行的文件的名称)运行程序:

go run main.go


在程序的输出中,您应该会看到从牌组中随机选择的一张牌,以及牌的花色和等级:

Output
--- drawing playing card ---
drew card: Q of Diamonds
card suit: Diamonds
card rank: Q


由于卡片是从牌组中随机抽取的,因此您的输出可能与上面显示的输出不同,但您应该会看到类似的输出。第一行是在从牌堆中随机抽取卡片之前打印的,然后在抽出卡片后打印第二行。您可以看到卡片的输出使用的是 PlayingCard 的 String 方法返回的值。最后,您可以看到将您的 interface{} 卡值声明为 *PlayingCard 值后打印的两行花色和排名输出。

在本节中,您创建了一个使用 interface{} 值来存储任何值并与之交互的 Deck,并创建了一个 PlayingCard 类型来充当该 Deck 中的卡片。然后,您使用牌组和扑克牌从牌组中随机选择一张牌,并打印出有关该牌的信息。

但是,要访问有关您绘制的 *PlayingCard 值的特定信息,您需要做一些额外的工作来将 interface{} 类型转换为可访问 Suit 和 Rank 字段的 *PlayingCard 类型。以这种方式使用 Deck 是可行的,但如果将 *PlayingCard 以外的值添加到 Deck 也会导致错误。通过更新您的 Deck 以使用泛型,您可以从 Go 的强类型和静态类型检查中受益,同时仍然具有接受 interface{} 值提供的灵活性。

使用泛型进行集合


在上一节中,您使用一个 interface{} 类型切片创建了一个集合。但是要使用这些值,您需要做一些额外的工作来将 interface{} 中的值转换为这些值的实际类型。但是,使用泛型,您可以创建一个或多个类型参数,它们的行为几乎类似于函数参数,但它们可以将类型作为值而不是数据来保存。这样,泛型提供了一种在每次使用泛型类型时用不同类型替换类型参数的方法。这就是泛型类型得名的地方。由于泛型类型可以与多种类型一起使用,而不仅仅是像 io.Reader 或 interface{} 这样的特定类型,它的泛型足以适应多个用例。

在本节中,您将在创建 Deck 实例而不是使用 interface{} 时将您的 Deck 类型更新为可以使用任何特定类型卡的泛型类型。

要进行第一次更新,请打开 main.go 文件并删除 os 包导入:

main.go

package main

import (
    "fmt"
    "math/rand"
    // "os" package import is removed
    "time"
)

正如您将在以后的更新中看到的,您不再需要使用 os.Exit 函数,因此删除此导入是安全的。

接下来,将您的 Deck 结构更新为泛型类型:

main.go

...

type Deck[C any] struct {
    cards []C
}

此更新引入了泛型用于在结构声明中创建占位符类型或类型参数的新语法。您几乎可以将这些类型参数视为类似于您将包含在函数中的参数。调用函数时,您为每个函数参数提供值。同样,在创建泛型类型值时,您为类型参数提供类型。

您会在结构名称 Deck 之后看到您在方括号 ([]) 内添加了一条语句。这些方括号允许您为结构定义一个或多个这些类型参数。

对于你的牌组类型,你只需要一个名为 C 的类型参数来表示牌组中牌的类型。通过在类型参数中声明 C any,您的代码会说,“创建一个名为 C 的泛型类型参数,我可以在我的结构中使用它,并允许它是任何类型”。在幕后,any 类型实际上是 interface{} 类型的别名。这使泛型更易于阅读,并且您无需使用 C 接口{}。您的套牌只需要一种通用类型来表示卡片,但如果您需要其他通用类型,您可以使用逗号分隔的语法添加它们,例如 C any、F any。如果没有保留,您用于类型参数的名称可以是您喜欢的任何名称,但它们通常很短且大写。

最后,在对 Deck 声明的更新中,您更新了结构中卡片切片的类型以使用 C 类型参数。使用泛型时,您可以在通常放置特定类型的任何地方使用类型参数。在这种情况下,您希望您的 C 参数表示切片中的每张卡片,因此您将 [] 切片类型声明放在 C 参数声明之后。

接下来,更新您的 Deck 类型的 AddCard 方法以使用您定义的泛型类型。现在,您将跳过更新 NewPlayingCardDeck 函数,但您很快就会回来:

main.go


...

func (d *Deck[C]) AddCard(card C) {
d.cards = append(d.cards, card)
}


在对 Deck 的 AddCard 方法的更新中,您首先将 [C] 泛型类型参数添加到方法的接收器。这让 Go 知道您将在方法声明的其他地方使用的类型参数的名称,并遵循与 struct 声明类似的方括号语法。但是,在这种情况下,您不需要提供 any 约束,因为它已经在 Deck 的声明中提供。然后,您更新了 card 函数参数以使用 C 占位符类型而不是原始 interface{} 类型。这使得使用特定类型的方法最终会变成 C。

更新 AddCard 方法后,更新 RandomCard 方法以使用 C 泛型类型:

main.go

...

func (d *Deck[C]) RandomCard() C {
    r := rand.New(rand.NewSource(time.Now().UnixNano()))

    cardIdx := r.Intn(len(d.cards))
    return d.cards[cardIdx]
}

这一次,您更新了方法以返回值 C 而不是 interface{},而不是使用 C 泛型类型作为函数参数。除了更新接收器以包含 [C] 之外,这是您需要对该函数进行的唯一更新。由于 Deck 上的 cards 字段已经在 struct 声明中进行了更新,因此当此方法从卡中返回值时,它返回的是 C 类型的值。

现在您的 Deck 类型已更新为使用泛型,返回到您的 NewPlayingCardDeck 函数并更新它以使用 *PlayingCard 类型的泛型 Deck 类型:

main.go

...

func NewPlayingCardDeck() *Deck[*PlayingCard] {
    suits := []string{"Diamonds", "Hearts", "Clubs", "Spades"}
    ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}

    deck := &Deck[*PlayingCard]{}
    for _, suit := range suits {
        for _, rank := range ranks {
            deck.AddCard(NewPlayingCard(suit, rank))
        }
    }
    return deck
}

...

NewPlayingCardDeck 中的大部分代码保持不变,但现在您使用的是通用版本的 Deck,您需要在使用 Deck 时指定要用于 C 的类型。你可以像往常一样引用你的 Deck 类型,无论是 Deck 还是像 *Deck 这样的引用,然后使用最初声明类型参数时使用的方括号提供应该替换 C 的类型。

对于 NewPlayingCardDeck 返回类型,您仍然像以前一样使用 *Deck,但是这一次,您还包括方括号和 *PlayingCard。通过为 type 参数提供 [*PlayingCard],您是说您希望在 Deck 声明和方法中使用 *PlayingCard 类型来替换 C 的值。这意味着 Deck 上的卡牌字段的类型基本上从 [ ]C 到 []*扑克牌。

类似地,当创建一个新的 Deck 实例时,您还需要提供替换 C 的类型。您通常可以使用 &Deck{} 对 Deck 进行新引用,而是将类型包含在方括号内以结束 &Deck[ *扑克牌]{}。

现在您的类型已更新为使用泛型,您可以更新您的 main 函数以利用它们:

main.go

...

func main() {
    deck := NewPlayingCardDeck()

    fmt.Printf("--- drawing playing card ---\n")
    playingCard := deck.RandomCard()
    fmt.Printf("drew card: %s\n", playingCard)
    // Code removed
    fmt.Printf("card suit: %s\n", playingCard.Suit)
    fmt.Printf("card rank: %s\n", playingCard.Rank)
}

这次您的更新是删除代码,因为您不再需要将 interface{} 值断言为 *PlayingCard 值。当您更新 Deck 的 RandomCard 方法以返回 C 并更新 NewPlayingCardDeck 以返回 *Deck[*PlayingCard] 时,它更改了 RandomCard 以返回 *PlayingCard 值而不是 interface{}。当 RandomCard 返回 *PlayingCard 时,表示 playCard 的类型也是 *PlayingCard 而不是 interface{},您可以立即访问 Suit 或 Rank 字段。

要在保存对 main.go 的更改后查看程序运行,请再次使用 go run 命令:

go run main.go


您应该会看到类似于以下输出的输出,但抽出的卡片可能会有所不同:

Output
--- drawing playing card ---
drew card: 8 of Hearts
card suit: Hearts
card rank: 8


尽管输出与使用 interface{} 的程序的先前版本相同,但代码本身更简洁一些,并且避免了潜在的错误。您不再需要对 *PlayingCard 类型进行断言,从而避免了额外的错误处理。此外,通过说您的 Deck 实例只能包含 *PlayingCard,不可能将 *PlayingCard 以外的值添加到卡片切片中。

在本节中,您将 Deck 结构更新为通用类型,从而更好地控制您的牌组的每个实例可以包含的卡片类型。您还更新了 AddCard 和 RandomCard 方法以接受通用参数或返回通用值。然后,您更新了 NewPlayingCardDeck 以返回包含 *PlayingCard 牌的 *Deck。最后,您删除了 main 函数中的错误处理,因为您不再需要它。

现在您的套牌已更新为通用套牌,您可以使用它来放置您想要的任何类型的卡牌。在下一节中,您将通过在程序中添加一种新类型的卡来利用这种灵活性。

使用具有多种类型的泛型


一旦你创建了一个泛型类型,比如你的 Deck,你就可以将它与任何其他类型一起使用。当您创建通用 Deck 的实例并希望它与 *PlayingCard 类型一起使用时,您唯一需要做的就是在创建值时指定该类型。要支持不同的类型,您可以将 *PlayingCard 类型替换为您想要使用的新类型。

在本节中,您将创建一个新的 TradingCard 结构类型来表示不同类型的卡片。然后,您将更新您的程序以创建一副*TradingCards。

要创建您的 TradingCard 类型,请再次打开您的 main.go 文件并添加定义:

main.go

...

import (
    ...
)

type TradingCard struct {
    CollectableName string
}

func NewTradingCard(collectableName string) *TradingCard {
    return &TradingCard{CollectableName: collectableName}
}

func (tc *TradingCard) String() string {
    return tc.CollectableName
}

此交易卡类似于您的扑克牌,但它没有 Suit 和 Rank 字段,而是有一个 CollectableName 字段来跟踪交易卡的名称。 它还包括 NewTradingCard 构造函数和 String 方法,类似于 PlayingCard。

现在,为填充有 *TradingCards 的 Deck 创建 NewTradingCardDeck 构造函数:

...

func NewPlayingCardDeck() *Deck[*PlayingCard] {
    ...
}

func NewTradingCardDeck() *Deck[*TradingCard] {
    collectables := []string{"Sammy", "Droplets", "Spaces", "App Platform"}

    deck := &Deck[*TradingCard]{}
    for _, collectable := range collectables {
        deck.AddCard(NewTradingCard(collectable))
    }
    return deck
}

当您创建或返回 *Deck 时,您已将 *PlayingCard 替换为 *TradingCard,但这是您需要对甲板进行的唯一更改。 您有一片特殊的 DigitalOcean 收藏品,然后您循环将每个 *TradingCard 添加到套牌中。 套牌的 AddCard 方法仍然以相同的方式工作,但这次它接受来自 NewTradingCard 的 *TradingCard 值。 如果你尝试从 NewPlayingCard 传递一个值,编译器会给你一个错误,因为它需要 *TradingCard,但你提供的是 *PlayingCard。

最后,更新你的 main 函数,创建一个新的 *TradingCards 牌组,用 RandomCard 随机抽取一张牌,并打印出牌的信息:

main.go

...

func main() {
    playingDeck := NewPlayingCardDeck()
    tradingDeck := NewTradingCardDeck()

    fmt.Printf("--- drawing playing card ---\n")
    playingCard := playingDeck.RandomCard()
    ...
    fmt.Printf("card rank: %s\n", playingCard.Rank)

    fmt.Printf("--- drawing trading card ---\n")
    tradingCard := tradingDeck.RandomCard()
    fmt.Printf("drew card: %s\n", tradingCard)
    fmt.Printf("card collectable name: %s\n", tradingCard.CollectableName)
}

在最后一次更新中,您使用 NewTradingCardDeck 创建了一个新的集换式卡组并将其存储在 tradingDeck 中。然后,由于您仍然使用与以前相同的牌组类型,您可以使用 RandomCard 从牌组中随机获取一张交易卡并打印出来。您还可以直接在 tradingCard 上引用和打印 CollectableName 字段,因为您使用的通用 Deck 已将 C 定义为 *TradingCard。

此更新还显示了使用泛型的价值。要支持一种全新的卡片类型,您根本不需要更改套牌。 Deck 的类型参数允许您在创建 Deck 实例时提供要使用的卡片类型,从那时起,与该 Deck 值的任何交互都使用 *TradingCard 类型而不是 *PlayingCard 类型。

要查看更新后的代码,请保存更改并使用 go run 再次运行程序:

go run main.go


然后,查看您的输出:

Output
--- drawing playing card ---
drew card: Q of Diamonds
card suit: Diamonds
card rank: Q
--- drawing trading card ---
drew card: App Platform
card collectable name: App Platform


程序完成运行后,您应该会看到与上面的输出类似的输出,只是使用不同的卡。你会看到两张牌被抽出来:原来的扑克牌和你添加的新交易牌。

在本节中,您添加了一种新的 TradingCard 类型来表示与原始 PlayingCard 不同的卡类型。添加 TradingCard 类型后,您创建了 NewTradingCardDeck 构造函数来创建并使用交易卡填充牌组。最后,您更新了 main 方法以使用新的集换式卡组并打印出有关正在抽取的随机卡的信息。

除了创建一个新函数 NewTradingCardDeck 来用不同的卡片填充牌组之外,您不需要对牌组进行任何其他更新以支持全新的牌类型。这就是泛型类型的力量。您可以编写一次代码,然后将其重新用于多种其他类型的类似数据。但是,您当前的 Deck 的一个问题是,由于您拥有 C any 声明,它可以用于任何类型。这可能是您想要的,以便您可以使用 &Deck[int]{} 创建一组 int 值。但是如果你希望你的 Deck 只包含卡片,你需要一种方法来限制 C 允许的数据类型。

限制泛型类型


通常,您不希望或不需要对泛型使用的类型进行任何限制,因为您不一定关心特定数据。但是,其他时候,您需要能够限制泛型使用的类型。例如,如果您正在创建一个泛型 Sorter 类型,您可能希望将其泛型类型限制为具有 Compare 方法的类型,以便 Sorter 可以比较它所持有的项目。如果您不包括该限制,则这些值甚至可能没有 Compare 方法,并且您的 Sorter 将不知道如何比较它们。

在本节中,您将为您的卡片创建一个新的 Card 接口以实现,然后更新您的 Deck 以仅允许添加卡片类型。

要开始更新,请打开 main.go 文件并添加您的 Card 接口:

...

import (
...
)

type Card interface {
    fmt.Stringer

    Name() string
}

您的 Card 接口的定义与您过去可能使用的任何其他 Go 接口相同; 将其与泛型一起使用没有特殊要求。 在此 Card 接口中,您说要被视为 Card,它必须实现 fmt.Stringer 类型(它必须具有您的卡已有的 String 方法),并且它还必须具有返回 a 字符串值。

接下来,更新您的 TradingCard 和 PlayingCard 类型以添加新的 Name 方法,除了现有的 String 方法,因此它们实现 Card 接口:

main.go

...

type TradingCard struct {
    ...
}

...

func (tc *TradingCard) Name() string {
    return tc.String()
}

...

type PlayingCard struct {
    ...
}

...

func (pc *PlayingCard) Name() string {
    return pc.String()
}

TradingCard 和 PlayingCard 已经有实现 fmt.Stringer 接口的 String 方法。所以要实现 Card 接口,只需要添加新的 Name 方法即可。此外,由于 fmt.Stringer 已经实现了返回卡片名称,因此您可以只返回 Name 的 String 方法的结果。

现在,更新您的 Deck,使其仅允许将 Card 类型用于 C:

main.go

...

type Deck[C Card] struct {
    cards []C
}

在此更新之前,您有 C any 作为类型限制(称为类型约束),这并不是什么限制。由于 any 与 interface{} 的含义相同,因此它允许将 Go 中的任何类型用于 C 类型参数。现在您已经用新的 Card 接口替换了 any,Go 编译器将确保在编译程序时用于 C 的任何类型都实现 Card。

由于您添加了此限制,您现在可以在您的 Deck 类型的方法中使用 Card 提供的任何方法。如果您希望 RandomCard 也打印出正在绘制的卡片的名称,它可以访问 Name 方法,因为它是 Card 接口的一部分。您将在下一节中看到这一点。

这几个更新是将您的套牌类型限制为仅使用卡片值所需进行的唯一更新。保存更改后,使用 go run 运行更新后的程序:

go run main.go


然后,一旦您的程序完成运行,请查看输出:

Output
--- drawing playing card ---
drew card: 5 of Clubs
card suit: Clubs
card rank: 5
--- drawing trading card ---
drew card: Droplets
card collectable name: Droplets


您会看到,除了选择不同的卡之外,输出并没有改变。由于您的更新仅将值限制为您已经使用的类型,因此您的程序的功能没有改变。

在本节中,您添加了新的 Card 接口并更新了 TradingCard 和 PlayingCard 以实现该接口。您还更新了 Deck 的类型约束以将其类型参数限制为仅实现 Card 接口的类型。

不过,到目前为止,您只创建了一个通用结构类型。除了创建泛型类型,Go 还允许你创建泛型函数。

创建泛型函数


Go 中的泛型函数与 Go 中的其他泛型类型具有非常相似的语法。当您考虑其他泛型类型具有类型参数时,制作泛型函数就是为这些函数添加第二组参数。

在本节中,您将创建一个新的 printCard 通用函数,并使用该函数打印出所提供卡片的名称。

要实现您的新功能,请打开您的 main.go 文件并进行以下更新:

main.go

...

func printCard[C any](card C) {
    fmt.Println("card name:", card.Name())
}

func main() {
    ...
    fmt.Printf("card collectable name: %s\n", tradingCard.CollectableName)

    fmt.Printf("--- printing cards ---\n")
    printCard[*PlayingCard](playingCard)
    printCard(tradingCard)
}

在 printCard 函数中,您将看到泛型类型参数熟悉的方括号语法,然后是括号中的常规函数​​参数。然后,在主函数中,您使用 printCard 函数打印出 *PlayingCard 和 *TradingCard。

您可能会注意到对 printCard 的调用之一包含 [*PlayingCard] 类型参数,而第二个调用不包含相同的 [*TradingCard] 类型参数。 Go 编译器能够通过您传递给函数参数的值来找出预期的类型参数,因此在这种情况下,类型参数是可选的。如果您愿意,还可以删除 [*PlayingCard] 类型参数。

现在,保存您的更改并使用 go run:: 再次运行您的程序

go run main.go


不过,这一次,你会看到一个编译器错误:

Output
# command-line-arguments
./main.go:87:33: card.Name undefined (type C has no field or method Name)


在 printCard 函数的类型参数中,您有 any 作为 C 的类型约束。当 Go 编译程序时,它希望只看到由 any 接口定义的方法,而没有。这是对类型参数使用特定类型约束的好处。为了在你的卡片类型上访问 Name 方法,你需要告诉 Go 唯一用于 C 参数的类型是 Cards。

最后一次更新你的 main.go 文件,用 Card 类型约束替换 any 类型约束:

main.go

...

func printCard[C Card](card C) {
    fmt.Println("card name:", card.Name())
}

然后,保存更改并使用 go run 运行程序:

go run main.go


您的程序现在应该成功运行:

Output
--- drawing playing card ---
drew card: 6 of Hearts
card suit: Hearts
card rank: 6
--- drawing trading card ---
drew card: App Platform
card collectable name: App Platform
--- printing cards ---
card name: 6 of Hearts
card name: App Platform


在这个输出中,你会看到两张牌都像你熟悉的那样被绘制和打印,但是现在 printCard 函数也打印出这些牌并使用它们的 Name 方法来获取要打印的名称。

在本节中,您创建了一个新的通用 printCard 函数,该函数可以获取任何 Card 值并打印名称。您还看到了使用 any 类型约束而不是 Card 或其他特定值如何影响可用的方法。

结论


在本教程中,您创建了一个新程序,它带有一个 Deck,它可以从牌组中返回一张随机牌作为 interface{} 值,以及一个 PlayingCard 类型来表示牌组中的一张扑克牌。然后,您更新了 Deck 类型以支持泛型类型参数,并且能够删除一些错误检查,因为泛型类型确保该类型的错误不再发生。之后,您创建了一个新的 TradingCard 类型来表示您的牌组可以支持的不同类型的牌,以及创建每种牌组的牌组并从每个牌组返回一张随机牌。接下来,您向 Deck 添加了类型约束,以确保只有实现 Card 接口的类型才能添加到 Deck。最后,您创建了一个通用 printCard 函数,该函数可以使用 Name 方法打印任何 Card 值的名称。

在代码中使用泛型可以显着清理支持同一代码的多种类型所需的代码量,但也有可能过度使用它们。使用泛型而不是接口作为值存在性能和代码易读性的权衡,所以如果你可以使用接口而不是泛型,最好选择接口。然而,泛型仍然是非常强大的工具,当在他们擅长的情况下使用时,可以让开发人员的生活更轻松。

如果您想了解有关如何在 Go 中使用泛型的更多信息,Go 网站上的教程:泛型入门教程更详细地介绍了如何使用它们,以及使用类型约束的其他方法,而不仅仅是接口.

文章链接