Os genéricos estão chegando, quando você aplica genéricos e quando aplica interfaces?

Da conta pública: Gopher refere-se ao norte

1.jpg

o que são genéricos

Os genéricos permitem que você escreva estruturas de dados e funções primeiro e especifique os tipos dentro delas posteriormente. No Go atual, é claro que as funções têm parâmetros e, com os genéricos, as funções podem ter um novo tipo de parâmetro chamado "parâmetro de tipo". E o próprio tipo, que antes não podia ter nenhum parâmetro, também podia ter parâmetros de tipo. Funções e tipos que usam parâmetros de tipo podem ser instanciados usando argumentos de tipo.

Para parâmetros de tipo, diríamos "instanciar" em vez de chamar. Isso ocorre porque as operações relevantes ocorrem inteiramente em tempo de compilação e não em tempo de execução. Os parâmetros de tipo têm restrições, restringindo o conjunto de argumentos de tipo permitidos, assim como os parâmetros normais têm o conjunto normal de argumentos permitidos pelas restrições de tipo. Por exemplo, a função abaixo recebe um map[string]intparâmetro de tipo e retorna uma fatia de todas as chaves desse tipo.

func MapKeys(m map[string]int) []string{
    var s []string
    for k := range m {
        s = append(s, k)
    }
}
return s
复制代码

Você poderia facilmente mapescrever essa função para qualquer tipo específico, mas precisaria escrever uma cópia diferente da função para cada tipo de mapeamento que deseja usar. Alternativamente, esta função também pode ser escrita usando o pacote reflect, mas é trabalhoso escrever e a função é executada de forma relativamente lenta. O processo de usar o pacote reflect é muito complicado, então não vou dar um exemplo. Agora, você também pode usar parâmetros de tipo, com os quais você só precisa escrever esta função uma vez, e funciona para todos os tipos mapeados. Ao mesmo tempo, o compilador realizará uma verificação de tipo abrangente nele.

func MapKeys[K comparable, V any](m map[K]V) []K {
    var s []K
    for k := range m {
        s = append(s, k)
    }
    return s
}
复制代码

上述代码中,类型参数名为KV,普通参数m以前的类型为map[string]int,现在的类型为map[K]V。类型参数K是映射的键类型因此必须可以进行比较,这通过为K预先声明comparable限制条件来明确表达,你可以将其视为该类型参数的元类型。类型参数V可以是任意类型,因此它的限制是预先声明的限制条件any。函数主体和以前一样,只是变量s现在是k的类型切片而不是字符串的类型切片。关于泛型还有很多其他细节,在这里就不继续讨论了,有兴趣的可以去阅读官方的使用手册。非常重要的一点是,虽然这个示例中没有展示,但实际上类型本身也可以有类型参数。

什么时候适用泛型

言归正传,今天并不是要讨论什么是泛型或者如何使用他们,而是讨论在什么情况下应该适用泛型以及什么情况下不适用。需要明确的是这个讲座只提供一般指导并不是硬性规定,具体情况由你自行判断。但是,如果你不确定,可以参考我将要讨论的准则。

首先,我来谈谈使用Go编程的一般准则。我们是通过编写代码来编写Go程序而不是通过定义类型。

Write code, don't design types
复制代码

当涉及到泛型时,如果你通过定义类型参数限制条件来开始编写程序则可能走错了方向。首先,应编写函数,然后当你清楚地看到可以使用类型参数时再轻松地添加。

为了说明这一点,现在我们看看类型参数在什么情况下可能有用。

  • 类型参数可能有用的一种情况是对语言中定义的特殊类型进行操作的函数。例如,切片、映射和通道。

如果函数具有这些类型的参数,并且函数代码没有对元素类型做出任何特定假设,那么使用类型参数可能会很有用。例如,我们之前看到的MapKeys函数。该函数返回映射中的所有键,代码没有对MapKeys的类型做出任何假设,因此该函数非常适用类型参数。正如我之前提到的,对此类函数使用类型参数的替代方法通常是使用反射。但,这是一个更笨拙的编程模型,因为其不仅无法以静态方式进行类型检查。而且通常运行也更慢。

  • 类型参数可能有用的另一个类似情况是通用数据结构。

我所说的通用数据结构是指切片或映射等数据结构,但没有内置到语言中。例如,链表或者二叉树等。目前,需要此类数据结构的程序使用特定的元素类型进行编写或者使用接口类型。将特定元素类型替换为类型参数可以生成更通用的数据结构。将接口类型替换为类型参数通常可以更高效地存储数据。在某些情况下,使用类型参数而不是接口类型可能意味着代码可以避免类型断言,而且可以在编译时就进行全面的类型检查。例如,使用类型参数的二叉树数据结构看上去可能是下面这样。

type Tree[T any] struct {
    cmp func(T, T) int
    root *leaf[T]
}

type leaf[T any] struct {
    val T
    left, right *leaf[T]
}
复制代码

树中的每个叶节点都包含类型参数T的值,当使用特定的类型实参将该二叉树实例化时,该类型实参的值将直接存储在叶子节点中,而不会作为接口类型存储。

下面的示例展示了通用二叉树中的方法。

func (bt *Tree[T]) find(val T) *leaf[T] {
    pl := bt.root
    for pl != nil {
        switch cmp := bt.cmp(val, pl.val); {
        case cmp < 0: pl = pl.left
        case cmp > 0: pl = pl.right
        default: return pl
        }
    }
    return pl
}
复制代码

请不用过多在意上述代码的细节,在实际的使用过程中也不用生搬硬套将上述代码作为模版。重点是,这是对类型参数的合理使用,因为树数据结构和find方法中的代码在很大程度上独立于元素类型T。树数据结构确实需要知道如何比较元素类型T的值,它使用一个传入的函数来实现此目的。你可以在代码的第四行对bt.cmp的调用中看到这一点,除此之外,类型参数没有任何其他作用。同时,这个二叉树示例展示了另一条一般准则。

  • 当你需要使用比较函数等功能时,最好使用函数,而不是方法。

我们本来可以将这个树类型定义为该元素类型需要一个compare或less方法。因此可以编写一个需要compare或less方法的限制条件,这意味着用于实例化树类型的任何实参都需要具有该方法,同时意味着如果有任何人想使用具有简单数据类型(如int)的树,都必须使用compare方法定义自己的int类型,并且任何人想使用具有自定义数据类型的树也必须为他们的数据类型定义compare方法(即使本来并不需要)。如果我们将树定义为接受一个函数,就像上面的代码中那样,就可以轻松的传入所需的比较函数。如果元素碰巧已经有一个compare方法,我们只需要传入方法表达式即可。换句话说,将方法转换为函数,比将方法添加到类型要简单的多。因此,对于通用数据类型,最好使用函数而不是编写需要方法的限制条件。

  • 类型参数可能有用的另一种情况是,当不同的类型需要实现一些通用的方法,而针对各种类型的实现看起来都相同时。

例如,考虑使用Sort包中标准库的sort.Interface,它要求每个类型实现三个方法,即lenswapless。下面示例为一种为任何切片类型实现sort.Interface的泛型类型。

type SliceFn(T any) struct {
    s []T
    cmp func(T, T) bool
}

func (s SliceFn[T]) Len() int { return len(s.s) }

func (s SliceFn[T]) Swap(i, j int) {
    s.s[i], s.s[j] = s.s[j], s.s[i]
}

func (s SliceFn[T]) Less(i, j int) int {
    return s.cmp(s.s[i], s.s[j])
}

复制代码

对于任何切片类型而言,len和swap方法完全相同,less方法则需要一个比较函数,也就是slicFn名称中的“Fn”部分。与前面的树示例一样,我们将在创建sliceFn时传入一个函数。下面示例展示了如何使用sliceFn通过比较函数对任何切片进行排序。

func SortFn[T any](s []T, cmp func(T, T) bool) {
    return sort.Sort(SLiceFn[T]{s, cmp})
}
复制代码

在此示例中,非常适合使用类型参数。因为所有切片类型对应的方法看起来都完全相同。当你需要实现对于所有相关类型看起来相同的方法时,使用类型参数是合理的做法。

什么时候不适用泛型

现在,我们来谈一谈问题的另一面。什么情况下不适用泛型。

什么时候使用类型参数不是个好主意?Go具有接口类型,接口类型已经允许某种泛型编程。例如,广泛使用的io.Reader接口提供了一种通用机制用于从包含信息(如文件)或生成信息(如随机数生成器)的任何值中读取数据。

  • 对于某个类型的值,如果你只需对该值调用一个方法,请使用接口类型而不是类型参数。

io.Reader易于读取,有效且高效。从值中读取数据时,比如像调用Read方法不需要使用类型参数。例如,不要编写下面这样的代码。

func ReadFour[T io.Reader](r T) ([]byte, error) {
    buf := make([]byte, 4)
    _, err := io.ReadFull(r, buf)
    if err != nil {
        return nil, err
    }
    return buf, nil
    
}
复制代码

上面代码中即使不使用类型参数也可以编写相同的函数,而且省略类型参数将使函数更易于编写和更易于阅读,并且执行时间可能相同。

最后值得强调的一点是,人们可能会假设使用特定类型实参实例化的函数往往比使用接口方法的代码稍快。当然,在Go中,确切的细节将取决于编译器,与使用接口方法的类似代码相比,使用类型参数实例化的函数很有可能并不会更快。因此,不要出于效率考虑使用类型参数。使用它们的原因是能让你的代码更清晰。如果他们使你的代码更复杂,请不要使用。

现在,回到类型参数与接口类型之间的选择。当不同类型使用一个共同的方法时,考虑该方法的实现。

  • 前面我们说过,如果一个方法实现对于所有类型都相同则使用类型参数,相反如果每种类型的实现各不相同,请使用不同的方法不要使用类型参数。

例如,从文件读取的实现与从随机数生成器读取的实现完全不同,这意味着我们要编写两种不同的读取方法,并且两种方法都不应使用类型参数。

虽然我今天只提到了几次,Go也有反射,反射确实允许进行某种通用编程。它允许你编写适用于任何类型的代码。如果某些操作必须支持没有方法的类型那么接口类型就不起作用,并且如果每种类型的操作都不相同请使用反射。这方面的一个例子是json编码包。我们不要求我们编码的每个类型都支持marshal json方法,因此我们不能使用接口类型。同时,对整数类型编码和对结构类型编码完全不同,因此我们不能使用类型参数。软件包中使用的是反射,相关代码太复杂,不方便在这里展示,但如果你有兴趣请查阅相关源码。

总结

Toda a discussão acima pode ser reduzida a uma simples diretriz.

  • Se você estiver escrevendo exatamente o mesmo código várias vezes, onde a única diferença antes de cada versão é que o código usa tipos diferentes, considere se você pode usar parâmetros de tipo. [Outra maneira de expressar isso é que você deve evitar usar parâmetros de tipo até perceber que está escrevendo exatamente o mesmo código várias vezes]

Finalmente, espero que os leitores possam usar genéricos em Go de forma prudente e razoável, e espero sinceramente que este artigo possa ser de alguma ajuda para os leitores.

Acho que você gosta

Origin juejin.im/post/7086253993738895390
Recomendado
Clasificación