sinais, passo 5: busca por palavras inteiras

A versão MVP1 do programa sinais busca caracteres comparando uma substring do nome. Isso gera dois problemas:

U+1F638 😸 	GRINNING CAT FACE WITH SMILING EYES
U+1F63A 😺 	SMILING CAT FACE WITH OPEN MOUTH
U+1F63B 😻 	SMILING CAT FACE WITH HEART-SHAPED EYES

TEORIA: na área de recuperação de informação (information retrieval) esses problemas são caracterizados por duas métricas: precisão e revocação (precision, recall). Resultados demais é falta de precisão: o sistema está recuperando resultados irrelevantes, ou encontrando falsos positivos. Resultados de menos é falta de revocação: o sistema está deixando de recuperar resultados relevantes, ou seja, falsos negativos.

Vamos melhorar a precisão e a revocação pesquisando por palavras inteiras. Poderíamos resolver o problema todo mexendo apenas na função Listar, mas isso deixaria ela muito grande e difícil de testar. Então vamos colocar um pouco das novas funcionalidades na função AnalisarLinha e em outras funções que criaremos aos poucos.

Melhorias em AnalisarLinha

Em vez de devolver apenas o código e o nome do caractere, vamos fazer a função AnalisarLinha devolver também as palavras do nome, na forma de uma lista de strings. Em Go, uma lista de strings é representada pela notação []string, que lê-se como uma fatia de strings (slice of strings).

Para começar, mudamos o teste TestAnalisarLinha:

func TestAnalisarLinha(t *testing.T) {
	runa, nome, palavras := AnalisarLinha(linhaLetraA) // ➊
	if runa != 'A' {
		t.Errorf("Esperado: 'A'; recebido: %q", runa)
	}
	const nomeA = "LATIN CAPITAL LETTER A"
	if nome != nomeA {
		t.Errorf("Esperado: %q; recebido: %q", nomeA, nome)
	}
	palavrasA := []string{"LATIN", "CAPITAL", "LETTER", "A"} // ➋
	if !reflect.DeepEqual(palavras, palavrasA) { // ➌
		t.Errorf("\n\tEsperado: %q\n\trecebido: %q", palavrasA, palavras) // ➍
	}
}

➊ Incluímos a variável palavras, que vai receber a []string.

➋ Criamos a variável palavrasA, com o valor esperado.

➌ Em Go, fatias não são comparáveis diretamente, ou seja, os operadores == e != não funcionam com elas. Porém o pacote reflect oferece a função DeepEqual, que compara estruturas de dados em profundidade. reflect.DeepEqual é particularmente útil em testes, mas em geral deve ser evitada no código do programa em si, por razões apresentadas logo abaixo.

➍ Usamos "\n\t" para exibir este erro em linhas separadas e indentadas no mesmo nível, para facilitar a comparação visual do esperado com o recebido. Coloque "X" no lugar de "A" na variável palavrasA para forçar o erro e ver o formato da mensagem. Também geramos as outras mensagens de erro para usar as palavras “esperado/recebido”, por consistência.

NOTA: O pacote reflect traz para a Go recursos poderosos e convenientes de linguagens dinâmicas, como por exemplo comparar ou copiar facilmente estruturas de dados complexas. Para quem tem experiência com linguagens de nível mais alto como Python, Ruby, JavaScript e PHP, é tentador sair usando reflect em seus programas Go. No entanto, a comunidade Go recomenda evitar abusar de reflect, por dois motivos principais: desempenho e salvaguardas de tipo (type safety).

O desempenho de uma função como DeepEqual pode ser uma ordem de grandeza inferior ao código equivalente otimizado para os tipos de dados envolvidos. E a natureza dinâmica das funções de reflect possibilita a ocorrência de erros em tempo de execução que seriam capturados pelo compilador, se o seu código fosse escrito declarando os tipos específicos.

No entanto, para escrever testes vale a pena usar o reflect.DeepEqual. Desempenho não é uma prioridade nos testes, e podemos abrir mão de algumas salvaguardas de tipo nos testes, porque elas continuam valendo em nosso código principal (onde não usamos reflect).

As mudanças necessárias para satisfazer este teste são simples:

// AnalisarLinha devolve a runa, o nome e uma fatia de palavras que
// ocorrem no campo nome de uma linha do UnicodeData.txt
func AnalisarLinha(linha string) (rune, string, []string) { // ➊
	campos := strings.Split(linha, ";")
	código, _ := strconv.ParseInt(campos[0], 16, 32)
	palavras := strings.Fields(campos[1]) // ➋
	return rune(código), campos[1], palavras // ➌
}

➊ Na declaração de AnalisarLinha, acrescentamos o tipo de mais um valor a ser devolvido: []string.

➋ Produzimos a fatia de palavras do nome, usando strings.Fields que é como strings.Split, mas usa como separador qualquer caractere Unicode considerado whitespace.

➌ Devolvemos a fatia de palavras, além da runa e seu nome.

Além disso, para poder compilar o programa e rodar o teste, precisamos mexer na função Listar onde invocamos AnalisarLinha, para aceitar a fatia de palavras devolvida como terceiro resultado, mesmo ignorando esse valor por enquanto:

    runa, nome, _ := AnalisarLinha(linha)

Isso satisfaz o teste de AnalisarLinha. Mas para fazer Listar trabalhar com a fatia de palavras, várias mudanças serão necessárias.

Consultas com várias palavras em Listar

O trecho que precisa ser melhorado em Listar é este:

		runa, nome, _ := AnalisarLinha(linha)
		if strings.Contains(nome, consulta) {
		 	fmt.Printf("U+%04X\t%[1]c\t%s\n", runa, nome)
		}

Em vez de procurar a string consulta dentro do nome, agora vamos procurar cada palavra da consulta na lista de palavras devolvida por AnalisarLinha. Em Python isso poderia ser feito facilmente em uma linha de código, pela subtração de conjuntos (tipo set). Infelizmente, Go por enquanto não tem um tipo set. Go não tem sequer uma função na biblioteca padrão que diga se uma string está presente em uma fatia de strings. Então o jeito é arregaçar a manga e codar, guiados por testes.

Primeiro vamos implementar a função contém, que devolve true se uma fatia de strings contém uma determinada string. Para verificar três casos em uma função de teste, vamos usar um teste em tabela.

Para decifrar a elaborada sintaxe marcada com ➊, ➋, ➌ e ➍ em TestContém (mais abaixo), vale a pena ver um caso mais simples da mesma sintaxe. Suponha que você quer declarar e inicializar uma variável com uma fatia de bytes. Essa seria uma forma de fazê-lo:

var octetos = []byte{10, 20, 30}

Repare que temos a palavra reservada var, seguida do identificador da variável octetos, um sinal =, e um valor literal do tipo []byte. Valores literais de tipos compostos em Go são escritos assim: o identificador do tipo, seguido de zero ou mais itens ou campos entre chaves: []byte{10, 20, 30}.

Agora vamos analisar TestContém, que usa uma declaração var semelhante, apenas mais extensa:

func TestContém(t *testing.T) {
	var casos = []struct { // ➊
		fatia     []string
		procurado string
		esperado  bool
	}{ // ➋
		{[]string{"A", "B"}, "B", true},
		{[]string{}, "A", false},
		{[]string{"A", "B"}, "Z", false}, // ➌
	} // ➍
	for _, caso := range casos { // ➎
		obtido := contém(caso.fatia, caso.procurado) // ➏
		if obtido != caso.esperado {                 // ➐
			t.Errorf("contém(%#v, %#v) esperado: %v; recebido: %v",
				caso.fatia, caso.procurado, caso.esperado, obtido) // ➑
		}
	}
}

➊ Esta declaração var cria a variável casos e atribui a ela uma fatia de struct anônima. A struct é declarada dentro do primeiro par de {} com três campos: uma fatia de strings, uma string e um booleano.

➋ Completando a declaração var, o segundo par de {} contém o valor literal da []struct, que são três itens delimitados por {}, sendo que cada item é formado por uma fatia de strings, uma string e um booleano.

➌ É obrigatório incluir essa vírgula ao final do último item de um literal composto de várias linhas, se você quiser fechar a chave do literal na próxima linha como fizemos aqui.

➍ Aqui termina a declaração var que começou em ➊.

➎ Usamos a sintaxe de laço for/range para percorrer os três itens de casos. A cada iteração, o for/range produz dois valores: um índice a partir de zero (que descartamos atribuindo a _) e o valor do item correspondente, que atribuímos a caso.

➏ Invocamos contém, passando os valores de caso.fatia e caso.procurado. A função tem que devolver true se caso.fatia contém o item caso.procurado.

➐ Comparamos o resultado recebido com caso.esperado. Se forem diferentes…

➑ …mostramos os argumentos passados e os valor obtido.

A implementação de contém é bem mais simples que o TestContém:

func contém(fatia []string, procurado string) bool { // ➊
	for _, item := range fatia {
		if item == procurado {
			return true // ➋
		}
	}
	return false // ➌
}

contém aceita uma fatia de strings e uma string, devolvendo true se a string é igual a um dos itens da fatia.

➋ Devolvemos true imediatamente assim que um item da fatia é igual ao texto procurado.

➌ Se chegamos até aqui, é porque o procurado não foi encontrado; devolvemos false.

A função contém é o primeiro tijolo da solução de busca por várias palavras. Agora precisamos de outra função auxiliar, contémTodos que devolve true se uma fatia contém todos os itens de uma segunda fatia. Ou seja, se a segunda fatia é um sub-conjunto da primeira (isso já estaria pronto se Go tivesse o conceito de conjuntos em sua biblioteca padrão).

Usamos outro teste de tabela:

func TestContémTodos(t *testing.T) {
	casos := []struct { // ➊
		fatia      []string
		procurados []string
		esperado   bool
	}{ // ➋
		{[]string{"A", "B"}, []string{"B"}, true},
		{[]string{}, []string{"A"}, false},
		{[]string{"A"}, []string{}, true}, // ➌
		{[]string{}, []string{}, true},
		{[]string{"A", "B"}, []string{"Z"}, false},
		{[]string{"A", "B", "C"}, []string{"A", "C"}, true},
		{[]string{"A", "B", "C"}, []string{"A", "Z"}, false},
		{[]string{"A", "B"}, []string{"A", "B", "C"}, false},
	}
	for _, caso := range casos {
		obtido := contémTodos(caso.fatia, caso.procurados) // ➍
		if obtido != caso.esperado {
			t.Errorf("contémTodos(%#v, %#v)\nesperado: %v; recebido: %v",
				caso.fatia, caso.procurados, caso.esperado, obtido) // ➎
		}
	}
}

➊ Agora usamos uma declaração curta (short declaration), com o sinal := em vez de var. O efeito é o mesmo, assim como o resto da sintaxe.

➋ Aqui temos 7 casos de teste.

➌ Caso a fatia caso.procurados seja vazia, o resultado será sempre true.

➍ Para cada caso, invocamos contémTodos com os campos .fatia e .procurados.

➎ Caso o obtido não seja igual ao caso.esperado, mostramos os argumentos passados, o resultado obtido e o esperado. O verbo de formatação %#v mostra o valor usando a sintaxe literal de Go.

Veja a diferença na formatação. Aqui a mensagem usando apenas %v:

--- FAIL: TestContémTodos (0.00s)
	runefinder_test.go:73: contémTodos([A B C], [A B])
		esperado: true; recebido: false

E aqui, usando %#v para formatar os argumentos de contémTodos

$ go test
--- FAIL: TestContémTodos (0.00s)
	runefinder_test.go:73: contémTodos([]string{"A", "B", "C"}, []string{"A", "B"})
		esperado: true; recebido: false

Eisa a implementação de contémTodos, bem simples porque já temos contém:

func contémTodos(fatia []string, procurados []string) bool {
	for _, procurado := range procurados {
		if !contém(fatia, procurado) {
			return false
		}
	}
	return true
}

Aqui não há nenhuma novidade de sintaxe.

Vamos incluir outra função exemplo nos testes de Listar para cobrir uma consulta com mais de uma palavra:

func ExampleListar_duasPalavras() {
	texto := strings.NewReader(linhas3Da43)
	Listar(texto, "CAPITAL LATIN")
	// Output:
	// U+0041	A	LATIN CAPITAL LETTER A
	// U+0042	B	LATIN CAPITAL LETTER B
	// U+0043	C	LATIN CAPITAL LETTER C
}

Finalmente, faremos o ajuste em Listar para satisfazer o teste ExampleListar_duasPalavras. As mudanças são simples, porque toda a lógica interessante está em contém e contémTodos.

// Listar exibe na saída padrão o código, a runa e o nome dos caracteres Unicode
// cujo nome contem as palavras da consulta.
func Listar(texto io.Reader, consulta string) {
	termos := strings.Fields(consulta) // ➊
	varredor := bufio.NewScanner(texto)
	for varredor.Scan() {
		linha := varredor.Text()
		if strings.TrimSpace(linha) == "" {
			continue
		}
		runa, nome, palavrasNome := AnalisarLinha(linha) // ➋
		if contémTodos(palavrasNome, termos) {           // ➌
			fmt.Printf("U+%04X\t%[1]c\t%s\n", runa, nome)
		}
	}
}

➊ Criamos uma fatia termos com as palavras da string consulta.

➋ O terceiro resultado de AnalisarLinha é a lista de palavras do nome.

➌ Usamos contémTodos para checar se palavrasNome contém cada um dos termos.

Podemos criar um teste funcional do pacote para demonstrar o funcionamento de uma consulta com duas palavras, exibindo resultados onde tais palavras não aparecem em sequência no nome do caractere:

func Example_consultaDuasPalavras() { // ➊
	oldArgs := os.Args // ➋
	defer func() { os.Args = oldArgs }()
	os.Args = []string{"", "cat", "smiling"}
	main() // ➌
	// Output:
	// U+1F638	😸	GRINNING CAT FACE WITH SMILING EYES
	// U+1F63A	😺	SMILING CAT FACE WITH OPEN MOUTH
	// U+1F63B	😻	SMILING CAT FACE WITH HEART-SHAPED EYES
}

Agora você pode experimentar o programa com go run ou criar outro executável com go build para ver a nova funcionalidade em ação. Por exemplo, pesquisar peças pretas do Xadrez:

$ go build
$ ./sinais chess black
U+265A	♚	BLACK CHESS KING
U+265B	♛	BLACK CHESS QUEEN
U+265C	♜	BLACK CHESS ROOK
U+265D	♝	BLACK CHESS BISHOP
U+265E	♞	BLACK CHESS KNIGHT
U+265F	♟	BLACK CHESS PAWN

Ou ainda, o trem-bala japonês:

$ ./sinais bullet train
U+1F685	🚅	HIGH-SPEED TRAIN WITH BULLET NOSE

E mesmo com apenas uma palavra, os resultados são melhores. A busca por “cat” traz principalmente emojis com gatos, e não mais caracteres com as letras “CAT” em qualquer parte do nome.

$ ./sinais cat
U+A2B6	ꊶ	YI SYLLABLE CAT
U+101EC	𐇬	PHAISTOS DISC SIGN CAT
U+1F408	🐈	CAT
U+1F431	🐱	CAT FACE
U+1F638	😸	GRINNING CAT FACE WITH SMILING EYES
U+1F639	😹	CAT FACE WITH TEARS OF JOY
U+1F63A	😺	SMILING CAT FACE WITH OPEN MOUTH
U+1F63B	😻	SMILING CAT FACE WITH HEART-SHAPED EYES
U+1F63C	😼	CAT FACE WITH WRY SMILE
U+1F63D	😽	KISSING CAT FACE WITH CLOSED EYES
U+1F63E	😾	POUTING CAT FACE
U+1F63F	😿	CRYING CAT FACE
U+1F640	🙀	WEARY CAT FACE

Agora é um bom momento para exercícios. Veja instruções no Passo 6.