立即领取|华润集团、宁德核电、东风岚图等 20+ 标杆企业数字化人才培养实践案例 了解详情
写点什么

Go 中的泛型:激动人心的突破

  • 2022-04-07
  • 本文字数:8239 字

    阅读完需:约 27 分钟

Go中的泛型:激动人心的突破

一个特性改变一切。


在我们选择的编程语言中,我们多长时间会经历一次根本性的变化?有些语言会变化得更频繁一些,但还有些语言会比温布尔登更保守。


Go 语言就属于后者。有时对我来说它实在太古板了。“Go 不是这么写的!”是我梦到最多的一句话。Go 的多数新版本都只是对已有方向循序渐进的改善。


一开始,我并不觉得自己喜欢这样的路径。没什么新鲜事物刺激的话,总是用一种工具迟早会令人厌烦的。有时我宁愿看无聊的《与卡戴珊姐妹同行》也不想碰 Go 了。


(开个玩笑。我没装电视的一个原因就是想逃离那些可能污染我美丽眼球的电视节目。)


然后……新鲜血液终于来了。去年底,Go 团队宣布 1.18 版开始支持泛型,这可不是以前那种小打小闹的改进,也不是什么对开发人员行为絮絮叨叨的建议和约束。


打起精神来吧,革命来临了。

那么,什么是泛型?


泛型让我们能在定义接口、函数、结构时参数化类型。泛型不是什么新概念。我们从古老的 Ada 语言的第一个版本就开始使用它了,后来 C++中的模板也有泛型,直到 Java 和 C#中的现代实现都是很常见的例子。


不谈什么复杂的定义,我们来看看真实的例子——下面的代码中,泛型让我们得以避开许多 Max 或 Min 函数,而是写成:


func MaxInt(a, b int) int {  // some code}func MaxFloat64(a, b float64) float64 {  // some code}func MaxByte(a, b byte) byte {  // some code}
复制代码


只声明一个方法,如下所示:


func Max[T constraints.Ordered](a, b T) T {  // some code}
复制代码


等等,刚刚发生了什么?其实我们没有在 Go 中为每种类型都定义一个方法,而是使用了泛型——我们使用泛型类型,参数 T 作为这个方法的参数。通过这个小小的调整,我们就能支持所有 orderable 的类型。参数 T 代表满足 Ordered 约束的任何类型(稍后我们将讨论约束主题)。所以,一开始我们需要定义 T 是什么类型。


接下来,我们定义要在何处使用这个参数化类型。这里,我们确定输入和输出参数都是 T 类型。如果我们将 T 定义为整数来执行方法,那么这里的所有内容都是整数:


func main() {  fmt.Println(Max[int](1, 2))}//// this code behaves exactly like method:// Max(a, b int) int
复制代码


能做的不仅是这些。我们可以提供尽可能多的参数化类型。我们可以将它们分配给不同的输入和输出参数,随我们的喜好:


func Do[R any, S any, T any](a R, b S) T {  // some code}func main() {  fmt.Println(Do[int, uint, float64](1, 2))}//// this code behaves exactly like method:// Do(a int, b uint) float64
复制代码


这里我们有三个参数,R、S 和 T。正如我们从约束 any 中看到的那样(其行为类似于 interface{}),这些类型可以是任何东西。所以现在我们应该清楚了什么是泛型,以及我们如何在 Go 中使用它们了。下面我们来谈谈它带来的激动人心的影响。

如何在本地环境中启用泛型?


目前 Go 1.18 的稳定版本尚未发布。因此我们需要做一些调整来在本地对其进行测试。


为了启用泛型,我使用了 Jetbrains 的 Goland。我在他们的网站上找到了一篇有用的文章,用于设置在 Goland 中运行代码的环境。


与那篇文章的唯一区别是我使用了带有 master 分支的 Go 源代码(https://go.googlesource.com/go),而不是文章中的那个分支。


在 master 分支上,我们可以享用来自标准 Go 库的新包,Constraints

速度,我要的是速度

Go 中的泛型与反射是不一样的。在讲一些复杂的例子之前,我们有必要先检查一下泛型的基准测试分数。从逻辑上讲,我们并不指望它的性能接近反射,因为在这种情况下我们不需要泛型。


当然,泛型并不像反射,它也没打算做成那样。不过至少在某些用例中,泛型是生成代码的一种替代方法。


因此,这意味着我们想看到的是基于泛型的代码与“经典”执行的代码具有相同的基准测试结果。我们来检查一个基本案例:


package mainimport (  "constraints"  "fmt")type Number interface {  constraints.Integer | constraints.Float}func Transform[S Number, T Number](input []S) []T {  output := make([]T, 0, len(input))  for _, v := range input {    output = append(output, T(v))  }  return output}func main() {  fmt.Printf("%#v", Transform[int, float64]([]int{1, 2, 3, 6}))}////// Out:// []float64{1, 2, 3, 6}
复制代码


这里是将一种 Number 类型转换为另一种的小方法。Number 是我们基于 Go 标准库中的 Integer 和 Float 约束构建的约束(我们稍后将讨论这个主题)。Number 可以是 Go 中的任何数值类型:从 int 的任何衍生到 uint、float 等等。方法 Trasforms 会以第一个参数化数值类型 S 作为切片基数的切片,并将其转换为以第二个参数化数字类型 T 作为切片基数的切片。


简而言之,如果我们想将一个整数切片转换成一个浮点切片,我们会像在 main 函数中所做的那样调用这个方法。


我们函数的非泛型替代方法需要一个整数切片并返回一个浮点切片。因此,这就是我们将在基准测试中测试的内容:


func BenchmarkGenerics(b *testing.B) {  for i := 0; i < b.N; i++ {    Transform[int, float64]([]int{1, 2, 3, 6})  }}func TransformClassic(input []int) []float64 {  output := make([]float64, 0, len(input))  for _, v := range input {    output = append(output, float64(v))  }  return output}func BenchmarkClassic(b *testing.B) {  for i := 0; i < b.N; i++ {    TransformClassic([]int{1, 2, 3, 6})  }}////// Out:// goos: darwin// goarch: amd64// pkg: test/generics// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz//// first run:// BenchmarkGenerics// BenchmarkGenerics-8     38454709          31.80 ns/op// BenchmarkClassic// BenchmarkClassic-8      36445143          34.83 ns/op// PASS//// second run:// BenchmarkGenerics// BenchmarkGenerics-8     34619782          33.48 ns/op// BenchmarkClassic// BenchmarkClassic-8      36784915          31.78 ns/op// PASS//// third run:// BenchmarkGenerics// BenchmarkGenerics-8     36157389          33.38 ns/op// BenchmarkClassic// BenchmarkClassic-8      37115414          32.30 ns/op// PASS
复制代码


并没有惊喜。两种方法的执行时间几乎一样,也就是说使用泛型不会影响我们应用程序的性能。但是它对结构(struct)有影响吗?我们尝试一下。现在,我们将使用结构并将方法附加到它们上。测试任务没变——将一个切片转换为另一个切片:


type Transformer[S Number, T Number] struct {  slice []S}func (t *Transformer[S, T]) Do() []T {  output := make([]T, 0, len(t.slice))  for _, v := range t.slice {    output = append(output, T(v))  }  return output}func BenchmarkGenerics(b *testing.B) {  for i := 0; i < b.N; i++ {    object := Transformer[int, float64]{      slice: []int{1, 2, 3, 6},    }    object.Do()  }}type TransformerClassic struct {  slice []int}func (t *TransformerClassic) Do() []float64 {  output := make([]float64, 0, len(t.slice))  for _, v := range t.slice {    output = append(output, float64(v))  }  return output}func BenchmarkClassic(b *testing.B) {  for i := 0; i < b.N; i++ {    object := TransformerClassic{      slice: []int{1, 2, 3, 6},    }    object.Do()  }}////// Out:// goos: darwin// goarch: amd64// pkg: test/generics// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz//// first run:// BenchmarkGenerics// BenchmarkGenerics-8     29744370          33.81 ns/op// BenchmarkClassic// BenchmarkClassic-8      36323090          31.51 ns/op// PASS//// second run:// BenchmarkGenerics// BenchmarkGenerics-8     35238153          32.11 ns/op// BenchmarkClassic// BenchmarkClassic-8      37007353          31.80 ns/op// PASS//// third run:// BenchmarkGenerics// BenchmarkGenerics-8     34512194          33.12 ns/op// BenchmarkClassic// BenchmarkClassic-8      35426551          32.44 ns/op// PASS
复制代码


依旧没有惊喜。不管使用泛型还是经典实现都不会对 Go 代码的性能带来任何影响。是的,我们的确没有测试太复杂的用例,但如果有显著差异我们肯定已经看到了才对。所以,我们可以安心了。

约束

如果我们想测试更复杂的示例,添加任意参数化类型并运行应用程序是不够的。如果我们决定在没有任何复杂计算的情况下对一些变量做一个简单的示例,那么我们不需要添加什么特殊的东西:


func Max[T interface{}](a, b T) (T, T) {  return a, b}func main() {  fmt.Println(Max(1, 2))  fmt.Println(Max(3.0, 2.0))}////// Out:// 1 2// 3 2
复制代码


除了我们的方法 Max 不计算其输入的最大值而是将它们都返回之外,上面的示例并没有什么奇怪的地方。为此,我们使用一个定义为 interface{}的参数化类型 T。在这个示例中,我们不应将 interface{}视为一种类型,而应将其视为一种约束。我们使用约束来为我们的参数化类型定义规则,并为 Go 编译器提供一些关于期望的背景知识。


重复一遍:我们在这里不使用 interface{}作为类型,而是作为约束。我们为参数化类型定义各种规则,在这个例子中该类型必须支持 interface{}所做的任何事情。所以实际上,我们也可以在这里使用 any 约束。


(老实说,在所有示例中,我更喜欢 interface{}而不是 any,因为我的 Goland IDE 不支持新的保留字(any、comparable),然后我的 IDE 中出现了大量错误消息,自动完成也不能用了。)


在编译时,编译器可以接受一个约束,并使用它来检查参数化类型是否支持我们想要在以下代码中执行的运算符和方法。


由于编译器在运行时进行大部分优化工作(因此我们就不会影响运行时了,正如我们在基准测试中看到的那样),它只允许为特定约束定义的运算符和函数。


因此,要了解约束的重要性,我们来完成 Max 方法的实现并尝试比较 a 和 b 变量:


func Max[T any](a, b T) T {  if a > b {    return a  }  return b}func main() {  fmt.Println(Max(1, 2))  fmt.Println(Max(3.0, 2.0))}////// Out:// ./main.go:6:5: invalid operation: cannot compare a > b (operator > not defined on T)
复制代码


当我们尝试触发这个应用程序时得到一个错误——operator>not defined on T。因为我们将 T 类型定义为 any,所以最终类型可以是任何东西。从这里开始,编译器就不知道如何处理这个运算符了。为了解决这个问题,我们需要将参数化类型 T 定义为允许这种运算符的某种约束。感谢 Go 团队的出色表现,我们已经有了 Constraints 包,它就有这样的约束。


我们要使用的约束名为 Ordered,调整后的代码如此优雅:


func Max[T constraints.Ordered](a, b T) T {  if a > b {    return a  }  return b}func main() {  fmt.Println(fmt.Sprintf("%T", Max(1, 2)))  fmt.Println(Max(1, 2))  fmt.Println(fmt.Sprintf("%T", Max(3.0, 2.0)))  fmt.Println(Max(3.0, 2.0))  fmt.Println(fmt.Sprintf("%T", Max[int](1, 2)))  fmt.Println(Max[int](1, 2))  fmt.Println(fmt.Sprintf("%T", Max[int64](1, 2)))  fmt.Println(Max[int64](1, 2))  fmt.Println(fmt.Sprintf("%T", Max[float64](3.0, 2.0)))  fmt.Println(Max[float64](3.0, 2.0))  fmt.Println(fmt.Sprintf("%T", Max[float32](3.0, 2.0)))  fmt.Println(Max[float32](3.0, 2.0))}////// Out:// int --> Max(1, 2)// 2// float64 --> Max(3.0, 2.0)// 3// int --> Max[int](1, 2)// 2// int64 --> Max[int64](1, 2)// 2// float64 --> Max[float64](3.0, 2.0)// 3// float32 --> Max[float32](3.0, 2.0)// 3
复制代码


通过使用 Ordered 约束,我们得到了结果。这个例子的好处是我们可以看到编译器如何解释最终类型 T,这取决于我们传递给方法的值。我们无需在方括号中定义实际类型,就像前两种情况一样,编译器可以识别用于参数的类型——在 Go 中应该是 int 和 float64。


另一方面,如果我们想使用某些不是默认的类型,比如 int64 或 float32,就应该严格在方括号中传递这些类型。然后我们确切地编译器具体该做什么。


如果需要,我们可以扩展函数 Max 中的功能以支持在数组中搜索最大值:


func Max[T constraints.Ordered](a []T) (T, error) {  if len(a) == 0 {    return T(0), errors.New("empty array")  }  max := a[0]  for i := 1; i < len(a); i++ {    if a[i] > max {      max = a[i]    }  }  return max, nil}func main() {  fmt.Println(Max([]string{}))  fmt.Println(Max([]string{"z", "a", "f"}))  fmt.Println(Max([]int{1, 2, 5, 3}))  fmt.Println(Max([]float32{4.0, 5.0, 2.0}))  fmt.Println(Max([]float32{}))}////// Out://  empty array// z <nil>// 5 <nil>// 5 <nil>// 0 empty array
复制代码


在这个例子中我们可以看到两个有趣的点:


  1. 在方括号中定义类型 T 之后,我们可以在函数签名中以多种不同的方式使用它:简单类型、切片类型,甚至是映射的一部分。

  2. 当我们想要返回特定类型的零值时,我们可以使用 T(0)。Go 编译器足够聪明,可以将零值转换为所需的类型,例如第一种情况下的空字符串。我们可以看到比较某种类型的值是一种什么样的约束。通过 Ordered 约束,我们可以使用定义在整数、浮点数和字符串上的任何运算符。


如果我们想使用运算符==,可以使用一个新的保留字 comparable,这是一个仅支持此类运算符的唯一约束:


func Equal[T comparable](a, b T) bool {  return a == b}func Dummy[T any](a, b T) (T, T) {  return a, b}func main() {  fmt.Println(Equal("a", "b"))  fmt.Println(Equal("a", "a"))  fmt.Println(Equal(1, 2))  fmt.Println(Equal(1, 1))  fmt.Println(Dummy(5, 6))  fmt.Println(Dummy("e", "f"))}////// Out:// false// true// false// true// 5 6// e f
复制代码


在上面的示例中,我们可以看到 comparable 约束的用法应该是什么样的。同样,即使没有在方括号中严格定义它们,编译器也可以识别实际类型。示例中要提到的一点是,我们在两种不同的方法 Equal 和 Dummy 中为两种参数化类型使用了相同的字母 T。


每个 T 类型仅在这个方法的作用域(或结构及其方法)中定义,我们不会在其作用域之外谈论相同的 T 类型。我们可以用不同的方法重复同一个字母,类型仍然是相互独立的。

自定义约束

我们可以自定义约束,这很容易。约束可以是我们想要的任何类型,但最好的选择可能是使用接口:


type Greeter interface {  Greet()}func Greetings[T Greeter](t T) {  t.Greet()}type EnglishGreeter struct{}func (g EnglishGreeter) Greet() {  fmt.Println("Hello!")}type GermanGreeter struct{}func (g GermanGreeter) Greet() {  fmt.Println("Hallo!")}func main() {  Greetings(EnglishGreeter{})  Greetings(GermanGreeter{})}////// Out:// Hello!// Hallo!
复制代码


我们定义了一个 Greeter 接口,以便将它用作 Greetings 方法中的约束。不是为了演示的话,这里我们可以直接使用 Greeter 类型的变量而不是泛型。

类型集

每个类型都有一个关联的类型集。普通的非接口类型 T 的类型集只是包含 T 本身的集合{T}。接口类型的类型集(本节只讨论普通接口类型,没有类型列表)是声明接口所有方法的所有类型的集合。上面的定义来自类型集的提案。它已经加入了 Go 的源代码,所以我们可以在想要的任何地方使用它。


这一重大变更为我们带来了很多新的可能性:我们的接口类型也可以嵌入原始类型,如 int、float64、byte 而不仅仅是其他接口。这个特性使我们能够定义更灵活的约束。


检查以下示例:


type Comparable interface {  ~int | float64 | rune}func Compare[T Comparable](a, b T) bool {  return a == b}type customInt intfunc main() {  fmt.Println(Compare(1, 2))  fmt.Println(Compare(customInt(1), customInt(1)))  fmt.Println(Compare('a', 'a'))  fmt.Println(Compare(1.0, 2.0))}////// Out:// false// true// true// false
复制代码


我们定义了 Comparable 约束,而且那种类型看起来有点奇怪,对吧?Go 中使用类型集的新方法允许我们定义一个应该是类型联合的接口。为了描述两种类型之间的联合,我们应该将它们放在接口中,并在它们之间放置一个运算符:|。


因此在我们的示例中,Comparable 接口是以下类型的联合:rune、float64 和……我猜是 int?是的,它确实是 int,但这里定义为一个近似元素。


正如你在类型集的提案中看到的那样,一个近似元素 T 的类型集是类型 T 和所有基础类型为 T 的类型的类型集。


因此,仅仅因为我们使用了~int 近似元素,我们就可以将 customInt 类型的变量提供给 Compare 方法。如你所见,我们将 customInt 定义为自定义类型,其中 int 是底层类型。


如果我们没有添加操作符~,编译器就会抱怨,不会执行应用程序。

我们能走多远?

我们可以自由翱翔。说真的,这个特性彻底改变了 Go 语言。我的意思是,有许多新代码在不断出现。可能这会对依赖代码生成的那些包产生重大影响,比如Ent


从标准库开始,我已经可以看到许多代码会在未来的版本中被重构,转而使用泛型。泛型甚至可能推动一些 ORM 的发展,例如我们在Doctrine中看到的一样。


例如,考虑一个来自Gorm包的模型:


type ProductGorm struct {  gorm.Model  Name  string  Price uint}type UserGorm struct {  gorm.Model  FirstName string  LastName  string}
复制代码


想象一下,我们想在 Go 中为两个模型(ProductGorm 和 UserGorm)实现存储库模式。在当前的稳定版本的 Go 中,我们只能选择以下某种解决方案:


  1. 编写两个单独的存储库结构

  2. 编写一个应该使用模板来创建这两个存储库结构的代码生成器

  3. 决定不使用存储库现在有了泛型,我们就能转向更灵活的方法,可以这样做:


type Repository[T any] struct {  db *gorm.DB}func (r *Repository[T]) Create(t T) error {  return r.db.Create(&t).Error}func (r *Repository[T]) Get(id uint) (*T, error) {  var t T  err := r.db.Where("id = ?", id).First(&t).Error  return &t, err}
复制代码


所以,我们有了 Repository 结构,它有一个参数化类型 T,可以是任何东西。请注意,我们仅在 Repository 类型定义中定义了 T,并且只是将其分配的函数传递给它。这里我们只能看到 Create 和 Get 两个方法,只是为了演示而已。为了让演示更简单一些,我们创建两个单独的方法来初始化不同的 Repositories:


func NewProductRepository(db *gorm.DB) *Repository[ProductGorm] {  db.AutoMigrate(&ProductGorm{})  return &Repository[ProductGorm]{    db: db,  }}func NewUserRepository(db *gorm.DB) *Repository[UserGorm] {  db.AutoMigrate(&UserGorm{})  return &Repository[UserGorm]{    db: db,  }}
复制代码


这两种方法返回具有预定义类型的存储库实例。下面对这个小应用程序进行最终测试:


func main() {  db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})  if err != nil {    panic("failed to connect database")  }  productRepo := NewProductRepository(db)  productRepo.Create(ProductGorm{    Name:  "product",    Price: 100,  })  fmt.Println(productRepo.Get(1))  userRepo := NewUserRepository(db)  userRepo.Create(UserGorm{    FirstName: "first",    LastName:  "last",  })  fmt.Println(userRepo.Get(1))}////// Out:// &{{1 2021-11-23 22:50:14.595342 +0100 +0100 2021-11-23 22:50:14.595342 +0100 +0100 {0001-01-01 00:00:00 +0000 UTC false}}  100} <nil>// &{{1 2021-11-23 22:50:44.802705 +0100 +0100 2021-11-23 22:50:44.802705 +0100
复制代码


是的,它能行。一种 Repository 的实现,支持两种模型。零反射,零代码生成。我以为我永远不会在 Go 中看到这样的东西。我太高兴了,眼泪都快流出来了

总结

毫无疑问,Go 中的泛型是一个巨大的变化,可以迅速改变 Go 的使用方式,而且很快会在 Go 社区中引发许多重构。


虽然我几乎每天都在玩泛型,想看看我们还能用它做什么好东西,但我也迫不及待地想在稳定的 Go 版本中看到它们。革命万岁!


原文链接:https://levelup.gitconnected.com/generics-in-go-viva-la-revolution-e27898bf5495

2022-04-07 09:4110844
用户头像
刘燕 InfoQ高级技术编辑

发布了 1112 篇内容, 共 530.1 次阅读, 收获喜欢 1975 次。

关注

评论 1 条评论

发布
用户头像
泛型优雅的实现是 c#, go这泛型 和c# 相比 还在10里开外
2022-04-07 20:25
回复
没有更多了
发现更多内容

一步一腳印的 iOS App 上架和更新流程

雪奈椰子

ios apple 上架 apps

产研指南针的量化指标实践笔记

车江毅

项目管理 研发管理 降本增效 北极星指标 效能度量

舞台LED显示屏对灯光设计产生了哪些影响

Dylan

LED显示屏 全彩LED显示屏 led显示屏厂家

Apipost参数描述的填写和参数描述库的使用

爱研究代码的极客人

Postman 参数 参数定义 apipost

DAAM:首次利用视觉语言学解释大型扩散模型

Zilliz

云端智创 | 基于视频AI原理的音视频智能处理技术

阿里云视频云

云计算 音视频

带你动手做AI版的垃圾分类

华为云开发者联盟

人工智能 华为云 企业号 2 月 PK 榜 华为云开发者联盟 垃圾分类

快速入门API Explorer

华为云开发者联盟

云计算 华为云 API Explorer平台 企业号 2 月 PK 榜 华为云开发者联盟

Java程序员:为了跳槽刷完1000道真题,想不到老板直接给我升职了

程序知音

Java java面试 Java面试题 Java面试八股文 后端面试

【2.3-2.10】写作社区优秀技术博文一览

InfoQ写作社区官方

热门活动 优质创作周报

剖析字节案例,火山引擎A/B测试DataTester如何“嵌入”技术研发流程

字节跳动数据平台

大数据 AB testing实战 企业号 2 月 PK 榜

2023年互联网大厂泄露的这1300多道JAVA面试题,包含了程序员的所有技术点

架构师之道

Java 程序员 java面试

开心档之boostrap按钮组

雪奈椰子

bootstrap 开心档

一文详解数GaussDB(DWS)函数出参带出方式

华为云开发者联盟

数据库 后端 华为云 企业号 2 月 PK 榜 华为云开发者联盟

iOS AppStore上架流程图文详解2021版 (上)

雪奈椰子

ios apple 上架 apps

代码质量与安全 | 开发人员必备的安全编码实践指南

龙智—DevSecOps解决方案

代码安全 静态代码扫描

Fastjson踩“坑”记录和“深度”学习

阿里技术

Fastjson

MASA Stack 1.0 发布会讲稿——实践篇

MASA技术团队

.net MASA MAUI MASA Stack

选择等保测评机构需要注意的几个点-行云管家

行云管家

等保 等级保护 等保测评

全板电镀与图形电镀,到底有什么区别?

华秋电子

PCB PCB生产

JVM说--直接内存的使用

京东科技开发者

JVM io nio 虚拟机 企业号 2 月 PK 榜

职场IT老手教你3步教你玩转可视化大屏设计,让领导眼前一亮!

葡萄城技术团队

开心档之boostrap按钮2

雪奈椰子

bootstrap 开心档

模型推理耗时降低98%!PaddleTS又双叒叕带来重磅升级!

飞桨PaddlePaddle

paddle

在线研讨会邀请 | 赋能“大”研发,助力“快”交付

龙智—DevSecOps解决方案

版本控制 线上研讨会 研讨会 数字资产管理

开心档之bootstrap卡片

雪奈椰子

bootstrap 开心档

龙智宣布与Incredibuild建立战略合作伙伴关系

龙智—DevSecOps解决方案

DevSecOps 加速编译

如何在 Web 端实现一个多人数独游戏

声网

Vue 互动白板 RTE

开心档之boostrap轮播

雪奈椰子

bootstrap 开心档

ITSM | 限时优惠,帮助您的团队终结不良服务管理!

龙智—DevSecOps解决方案

Jira ITSM IT服务管理

模块1作业

王琨琨

架构实战营

Go中的泛型:激动人心的突破_AI&大模型_Marko Milojevic_InfoQ精选文章