Iteradores são uma ferramenta da linguagem que permite percorrer os elementos de uma coleção ou de um fluxo de dados sem precisar carregar tudo de uma vez na memória. No Go eles chegaram já no ano passado, e isso gerou uma grande repercussão: muita gente não gostou dessas novidades, os desenvolvedores não entendiam para que serviam nem onde usá-los, mas a reclamação que mais ouvi foi que os iteradores complicaram a linguagem ainda mais — e a simplicidade do Go é a base da filosofia da linguagem.
Desde o lançamento, eu, como muitos, nunca cheguei a usar iteradores no trabalho de verdade: simplesmente todas as minhas tarefas eram resolvidas com as ferramentas de sempre, e em nenhum momento surgiu algum problema que só pudesse ser resolvido com eles.
Neste artigo resolvi entender onde eu posso aplicar iteradores no trabalho real. Li um monte de artigos, opiniões de desenvolvedores, perguntei no reddit e, no fim, montei uma lista de casos em que os iteradores podem realmente ser úteis. Parte deles eu já consegui usar nos meus projetos de trabalho e, de fato, se mostraram bem convenientes.
Todos os casos de uso podem ser divididos logicamente em alguns grandes grupos.
Leitura preguiçosa de dados
Quando falamos do uso de iteradores ao trabalhar com um grande conjunto de dados, isso tem a ver, antes de tudo, com a leitura preguiçosa. Por exemplo, ao ler de um arquivo ou de um banco com vários milhões de linhas, não queremos carregar todas essas linhas na memória: queremos lê-las linha por linha e processá-las sequencialmente.
O caso de leitura preguiçosa mais comum no trabalho é a leitura do banco de dados, então vamos ver como aplicar um iterador a ele e por que isso pode ser conveniente.
Leitura linha por linha do banco com processamento adicional das linhas
Querendo ou não, muitas vezes em bancos relacionais precisamos armazenar dados em json ou em algum outro formato, e para isso a maioria dos bancos modernos até tem tipos específicos.
Imagine que estamos trabalhando com postgres e decidimos armazenar os dados no formato cbor — um formato análogo ao json, mas guardado em binário, mais eficiente em tamanho e velocidade. Queremos ler os dados de um arquivo e já fazer o unmarshal dos dados de cbor para uma struct de Go.
A forma clássica de ler os dados do banco de forma sequencial e sem carregar todas as linhas na memória é usar o método Next() do tipo sql.Rows do pacote padrão "database/sql":
rows, err = db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
return
}
fmt.Printf("User: %+v\n", user)
}Podemos extrair esse código para um repositório e chamá-lo onde for preciso. Mas se em algum lugar específico precisarmos processar a struct User de forma adicional (por exemplo, aparecer mais um campo, só que em formato json, que também vamos querer fazer o unmarshal), teremos que ou usar esse método, esperar o carregamento de todos os usuários na memória e depois filtrá-los mais uma vez, ou escrever outro método já com a lógica adicional.
Como alternativa, dá para fazer a mesma coisa usando iteradores:
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
return func(yield func(User) bool) {
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
return
}
if !yield(user) {
return
}
}
}
}
...
rows, err := db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
for user := range UsersFromRows(rows) {
fmt.Printf("User: %+v\n", user)
}Nesse caso podemos reaproveitar o método iterável UsersFromRows() e processar cada usuário da forma que julgarmos necessária, sem carregar todos os usuários na memória.
O código acima parece conveniente, mas falta nele um detalhe importante — o tratamento de erros. Iteradores não sabem retomar a execução depois de uma interrupção e não sabem repassar erros para fora. Por isso, de qualquer forma vamos ter que complicar a lógica para adicionar essa possibilidade. A maneira mais óbvia de fazer isso é colocar o erro ao lado do valor retornado:
type User struct {
ID int
Name string
Err error
}
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
return func(yield func(User) bool) {
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
yield(User{Err: err})
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
yield(User{Err: err})
return
}
if !yield(user) {
return
}
}
}
}
...
rows, err := db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
fmt.Printf("User: %+v\n", user)
}Trabalhando com APIs que oferecem paginação
Outro grupo são as APIs com paginação. No caso da paginação, não precisamos usar iteradores para economizar memória — a API já nos devolve uma quantidade fixa de elementos por página. Aqui os iteradores são uma ferramenta que encapsula a lógica de percorrer as páginas.
Um bom exemplo desse tipo de API com paginação foi dado por um usuário do reddit nos comentários do meu post com a pergunta se os desenvolvedores usam iteradores:
I have wrapped the various AWS paginated APIs with great results.
Vamos ver um exemplo real de uso de iteradores para lidar com a paginação da API da AWS.
Leitura paginada de arquivos do AWS S3
type Object struct {
Value types.Object
Err error
}
func S3Objects(ctx context.Context, client *s3.Client, bucket string) iter.Seq[Object] {
return func(yield func(Object) bool) {
var token *string
for {
resp, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: &bucket,
ContinuationToken: token,
})
if err != nil {
yield(Object{Err: err})
return
}
for _, obj := range resp.Contents {
if !yield(Object{Value: obj}) {
return
}
}
if resp.IsTruncated != nil && !*resp.IsTruncated {
break
}
token = resp.NextContinuationToken
}
}
}
func main() {
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
panic(err)
}
client := s3.NewFromConfig(cfg)
bucket := "some-bucket-name"
for obj := range S3Objects(ctx, client, bucket) {
fmt.Printf("Object: %s (%d bytes)\n", *obj.Value.Key, *obj.Value.Size)
}
}E isso, na minha opinião, é realmente conveniente. No momento estou trabalhando em um projeto que tem um monte de dependências, tanto entre serviços internos quanto entre externos, e metade delas usa paginação por tokens na API. E os iteradores, se usados com inteligência e com uma boa estrutura de projeto, podem simplificar muito o trabalho com esse tipo de API.
Pipelines de dados
Construir pipelines é outra possibilidade muito poderosa do uso de iteradores que descobri para mim.
Vamos olhar mais uma vez para o código que escrevemos acima para a leitura preguiçosa do banco:
type User struct {
ID int
Name string
Err error
}
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
return func(yield func(User) bool) {
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
yield(User{Err: err})
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
yield(User{Err: err})
return
}
if !yield(user) {
return
}
}
}
}
...
rows, err := db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
fmt.Printf("User: %+v\n", user)
}Agora imagine que, com o tempo, esse loop passou a ser usado em uma dezena de lugares no projeto, e em alguns deles surgiu uma filtragem adicional — por exemplo, em algum lugar é preciso exibir apenas os usuários ativos:
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
if !user.IsActive {
continue
}
fmt.Printf("User: %+v\n", user)
}E em outro lugar queremos limitar ainda mais a seleção de usuários a 10 elementos:
count := 0
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
if !user.IsActive {
continue
}
count++
if count >= 10 {
break
}
fmt.Printf("User: %+v\n", user)
}Em geral, com o tempo esse tipo de filtragem vai ficando cada vez mais frequente, e isso vai levar a ter dezenas de loops na base de código, em que parte da lógica se duplica e parte se diferencia, e não teremos como extraí-la e reutilizá-la.
Isso pode ser resolvido com pipelines — uma cadeia de iteradores em que os dados em fluxo passam sequencialmente por um conjunto de funções.
Por exemplo, queremos exibir apenas os usuários ativos, mostrando só os nomes deles, e limitar a seleção a 10 elementos. Para filtrar os usuários por algum parâmetro, podemos escrever um iterador adicional:
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
seq(func(item T) bool {
if pred(item) {
return yield(item)
}
return true
})
}
}Para mapear o usuário ao seu nome — o iterador Map:
func Map[T any, R any](seq iter.Seq[T], transform func(T) R) iter.Seq[R] {
return func(yield func(R) bool) {
seq(func(item T) bool {
return yield(transform(item))
})
}
}E também escrevemos um iterador adicional para limitar a seleção de usuários:
func Take[T any](seq iter.Seq[T], n int) iter.Seq[T] {
return func(yield func(T) bool) {
count := 0
seq(func(item T) bool {
if count >= n {
return false
}
count++
return yield(item)
})
}
}E o uso ficaria assim:
...
activeNames := Take(
Map(
Filter(UsersFromDB(db), func(u User) bool {
return u.Active
}),
func(u User) string {
return u.Name
},
),
10,
)
for name := range activeNames {
fmt.Println("active user:", name)
}Quais são as vantagens aqui:
Dá para combinar iteradores para filtrar ou transformar os dados em qualquer ordem, reduzindo a duplicação de código
Melhor testabilidade: basta cobrir com testes a lógica dos iteradores, e depois já podemos testar o pipeline inteiro em relação aos valores esperados
Conclusões
Iteradores são um mecanismo poderoso e, na verdade, bastante simples, desde que você trabalhe um pouco com eles e se acostume. Enquanto escrevia este artigo, testei um monte de exemplos de aplicação de iteradores, e os que destaquei acima com certeza vão entrar no meu arsenal.
Pretendo ir complementando este artigo à medida que descobrir novos casos interessantes, então se inscreve :)
Também mantenho um canal no telegram em inglês com um digest de materiais sobre Go:
https://t.me/the_dev_signalE um canal autoral no telegram, onde escrevo diversas anotações, ferramentas e compartilho experiências:
https://t.me/junsenior