Go 泛型切片工具入门:自己写 Filter、Map 和 Reduce

从切片工具函数学习泛型最自然 Go 1.18 有了泛型后,最容易上手的练习就是切片工具函数。因为切片处理在业务代码里非常常见:过滤活跃用户、提取 ID、计算总金额、把数据库结果转成响应结构体。过去这些函数要么针对具体类型写,要么使用 损失类型安全。泛型让它们变得更自然。

从切片工具函数学习泛型最自然

Go 1.18 有了泛型后,最容易上手的练习就是切片工具函数。因为切片处理在业务代码里非常常见:过滤活跃用户、提取 ID、计算总金额、把数据库结果转成响应结构体。过去这些函数要么针对具体类型写,要么使用 interface{} 损失类型安全。泛型让它们变得更自然。

这篇文章手写 Filter、Map 和 Reduce。它不是为了鼓励你把所有业务代码都写成函数式链条,而是通过简单例子理解类型参数如何传递。

Filter:保留符合条件的元素

func Filter[T any](items []T, keep func(T) bool) []T {
	result := make([]T, 0, len(items))
	for _, item := range items {
		if keep(item) {
			result = append(result, item)
		}
	}
	return result
}

使用:

type User struct {
	Name   string
	Active bool
}

users := []User{
	{Name: "小林", Active: true},
	{Name: "阿周", Active: false},
}

activeUsers := Filter(users, func(user User) bool {
	return user.Active
})

fmt.Println(activeUsers)

Filter 不关心 T 是什么类型,只把每个元素交给 keep 判断。这里 any 足够。

Map:把元素转换成另一种类型

func Map[T any, R any](items []T, convert func(T) R) []R {
	result := make([]R, 0, len(items))
	for _, item := range items {
		result = append(result, convert(item))
	}
	return result
}

提取用户名:

names := Map(users, func(user User) string {
	return user.Name
})

提取 ID:

type Article struct {
	ID    int64
	Title string
}

ids := Map(articles, func(article Article) int64 {
	return article.ID
})

T 是输入类型,R 是输出类型。编译器会根据调用自动推断,大多数时候你不需要手写类型参数。

Reduce:把列表折叠成一个值

func Reduce[T any, R any](items []T, initial R, combine func(R, T) R) R {
	result := initial
	for _, item := range items {
		result = combine(result, item)
	}
	return result
}

计算订单总金额:

type Order struct {
	ID         int64
	TotalCents int64
}

total := Reduce(orders, int64(0), func(sum int64, order Order) int64 {
	return sum + order.TotalCents
})

统计状态:

counts := Reduce(orders, map[string]int{}, func(acc map[string]int, order Order) map[string]int {
	acc[order.Status]++
	return acc
})

这个例子可以工作,但要注意 map 是引用类型,combine 修改的是同一个 map。这样写没错,但调用者要理解副作用。很多时候普通循环更直观:

counts := make(map[string]int)
for _, order := range orders {
	counts[order.Status]++
}

泛型工具不是必须替代所有循环。

链式调用要克制

你可以写:

names := Map(Filter(users, func(user User) bool {
	return user.Active
}), func(user User) string {
	return user.Name
})

这能运行,但可读性未必比普通循环好:

var names []string
for _, user := range users {
	if !user.Active {
		continue
	}
	names = append(names, user.Name)
}

Go 社区一直偏爱直接控制流。泛型工具适合减少重复,但不要为了追求“表达式化”让调试和阅读变难。

给工具函数写测试

泛型函数也应该测试。比如 Filter:

func TestFilter(t *testing.T) {
	got := Filter([]int{1, 2, 3, 4}, func(n int) bool {
		return n%2 == 0
	})

	want := []int{2, 4}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("got %v, want %v", got, want)
	}
}

再测字符串:

func TestFilterString(t *testing.T) {
	got := Filter([]string{"Go", "", "PHP"}, func(s string) bool {
		return s != ""
	})

	want := []string{"Go", "PHP"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("got %v, want %v", got, want)
	}
}

测试多个类型能帮助你确认泛型函数没有偷偷依赖某个具体类型。虽然编译器会检查类型约束,但业务语义仍然需要测试保护。

Contains 与去重

判断切片是否包含某个元素是常见需求。泛型版实现:

func Contains[T comparable](items []T, target T) bool {
	for _, item := range items {
		if item == target {
			return true
		}
	}
	return false
}

去重:

func Unique[T comparable](items []T) []T {
	seen := make(map[T]struct{}, len(items))
	result := make([]T, 0, len(items))
	for _, item := range items {
		if _, ok := seen[item]; !ok {
			seen[item] = struct{}{}
			result = append(result, item)
		}
	}
	return result
}

comparable 约束表示类型可以用 == 比较,这是 map key 和去重操作的前提。

常见问题 FAQ

Q: 泛型工具函数会不会让编译变慢?
A: 对普通项目影响很小。Go 编译器会对具体类型实例化模板,这是 Go 泛型设计的一部分。真正的编译负担来自大量不同类型组合,一般业务场景无需担心。

Q: 自定义类型可以用泛型函数吗?
A: 可以,只要类型满足约束。

type UserID int64
var ids = []UserID{1, 2, 3}
contains := Contains(ids, UserID(2))

Q: 性能比手写循环差吗?
A: 不会。编译后泛型函数就是对具体类型的直接调用,没有运行时开销。performance 与手写循环基本一致。

常见陷阱

  1. 链式调用性能: 连续 Map(Filter(...)) 会产生中间切片。大列表时可以考虑一次循环同时完成过滤和转换。
  2. 空值比较: 如果 T 包含指针或接口,== 语义可能和预期不同。去重要确认业务逻辑。
  3. 返回值引用: Reduce 里如果 R 是引用类型,combine 函数可能共享数据。

对比表

操作泛型写法适用场景
Filter保留满足条件的元素过滤无效数据、权限筛选
Map类型转换提取字段、DTO 转换
Reduce聚合求和、计数、构造复杂结构
Contains查找判断集合成员资格
Unique去重清理重复 ID、标签

性能提示

  1. 小切片(<100 元素)直接使用循环往往更清晰。
  2. 用 make([]T, 0, len(items)) 预分配容量能减少扩容分配。
  3. 如果对性能敏感,先用 benchmark 和 pprof 验证热点再优化。

小结

Filter、Map 和 Reduce 是理解 Go 泛型切片处理的好练习。它们展示了类型参数如何在输入、输出和函数参数之间传递,也展示了 any 约束适合没有额外操作要求的场景。扩展出的 Contains 和 Unique 则展示了 comparable 约束的实用价值。

真正写业务时,要根据可读性选择。简单转换用泛型工具很清楚,复杂流程用普通循环更稳。泛型让你多了选择,不是让你放弃 Go 的清楚风格。建议从具体项目中识别重复模式,再决定是否封装成泛型工具。

实战案例:电商订单数据处理

实际项目中,订单列表处理经常需要组合多种切片操作:

type Order struct {
    ID       int64
    UserID   int64
    Status   string
    Amount   float64
    Items    []string
}

// 合并使用泛型工具处理订单流
func ProcessOrders(orders []Order) ([]Order, float64, error) {
    // 过滤出已支付订单
    paid := Filter(orders, func(o Order) bool {
        return o.Status == "paid"
    })
    
    // 提取金额列表
    amounts := Map(paid, func(o Order) float64 {
        return o.Amount
    })
    
    // 计算总金额
    total := Reduce(amounts, 0.0, func(sum, amount float64) float64 {
        return sum + amount
    })
    
    // 获取包含电子产品的订单
    digitalOrders := Filter(paid, func(o Order) bool {
        return Contains(o.Items, "digital")
    })
    
    return digitalOrders, total, nil
}

Find 与 Index

查找首个满足条件的元素:

func Find[T any](items []T, match func(T) bool) (T, bool) {
    var zero T
    for _, item := range items {
        if match(item) {
            return item, true
        }
    }
    return zero, false
}

查找元素下标:

func Index[T comparable](items []T, target T) int {
    for i, item := range items {
        if item == target {
            return i
        }
    }
    return -1
}

GroupBy 分组

按某个属性分组是常见需求:

func GroupBy[T any, K comparable](items []T, keyFn func(T) K) map[K][]T {
    groups := make(map[K][]T)
    for _, item := range items {
        key := keyFn(item)
        groups[key] = append(groups[key], item)
    }
    return groups
}

使用:

byStatus := GroupBy(orders, func(o Order) string {
    return o.Status
})

Any 与 All

func Any[T any](items []T, pred func(T) bool) bool {
    for _, item := range items {
        if pred(item) {
            return true
        }
    }
    return false
}

func All[T any](items []T, pred func(T) bool) bool {
    for _, item := range items {
        if !pred(item) {
            return false
        }
    }
    return true
}

性能提示

  1. 预分配切片容量能显著减少内存分配次数。Filter/Map 中使用 make([]T, 0, len(items)) 是简单有效的优化。
  2. 大切片遍历时注意 cache locality。连续内存访问比随机访问更快。
  3. 避免在循环中重复计算:如果 keyFn 或 keep 函数内部有复杂逻辑,考虑缓存结果。
  4. 对超大切片(百万级),考虑并行处理。但 Go 的 goroutine 调度也有成本,只有计算密集型操作才值得并行化。

与标准库 slices 包的对比

Go 1.21+ 引入了 golang.org/x/exp/slices(未来可能进标准库),提供了不少类似功能:

import "golang.org/x/exp/slices"

slices.Contains(items, target)
slices.Index(items, target)
slices.Delete(items, i, j)

自己实现的优点是:完全控制行为、零依赖、适合学习泛型。标准库包的优势是:经过更多测试、生态兼容性更好。了解两者后可以根据项目需求选择。

最佳实践

  1. 命名清晰:泛型函数的命名和文档要明确约束条件和用途。
  2. 约束合理:不要为了炫技使用复杂约束。any 和 comparable 能覆盖 90% 以上场景。
  3. 组合而非堆砌:多种工具可以组合使用,但链式调用过多时考虑拆成中间变量。
  4. 保持可测试性:泛型函数覆盖多个类型,至少用 int、string 和自定义 struct 各测一次。

总结

泛型让 Go 切片处理代码更通用、更有表达力。但实际工程中,可读性仍然是首要目标。不要强行把所有循环都改成函数式调用,在"减少重复"和"容易理解"之间找到平衡。对大数量级的数据,先 benchmark 再决定是否替换。

类型约束进阶

Go 泛型支持自定义约束:

type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](items []T) T {
    var total T
    for _, v := range items {
        total += v
    }
    return total
}

~ 表示底层类型也匹配,因此自定义类型 type MyInt int 也能使用 Sum。

type MyInt int
var nums = []MyInt{1, 2, 3}
total := Sum(nums) // 合法

综合示例:分页数据处理

func Paginate[T any](items []T, page, pageSize int) ([]T, int) {
    if pageSize <= 0 {
        pageSize = 10
    }
    if page <= 0 {
        page = 1
    }
    totalPages := (len(items) + pageSize - 1) / pageSize
    start := (page - 1) * pageSize
    if start >= len(items) {
        return nil, totalPages
    }
    end := start + pageSize
    if end > len(items) {
        end = len(items)
    }
    return items[start:end], totalPages
}

分页结合泛型,适用于任何列表类型。DRY 原则在切片处理上体现得很充分。

小结回顾

泛型切片工具让代码更通用、更简洁。掌握 Filter、Map、Reduce、Contains、Unique、GroupBy 等基本工具后,业务中的集合操作会直观很多。但始终要记住:Go 推崇显式简单优于抽象复杂。在引入泛型工具前,先确认普通循环确实不够表达业务意图。

更多注意事项

使用泛型时还要注意编译器错误信息的可读性。类型不匹配时,编译器会展开实例化后的签名,可能很长。把约束设计得简单,报错信息也会更短,更容易看明白问题所在。此外,接口文档中对类型参数的记录也很重要:明确 T 是什么约束、R 输出什么、函数回调的参数顺序是什么,能帮助团队新人快速上手。

在实际工程里,泛型工具往往先从一个业务需求出发,提炼出通用模式,再封装成泛型函数。这个从具体问题到抽象工具的过程,是理解泛型最好的方式。不要一开始就想着编写一个"万能"工具库,先把眼前的重复代码解决掉,再逐步推广和沉淀。

建议团队根据实际项目需求,逐步建立内部的泛型工具包,形成符合团队习惯的工具集。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页