Поиск повторяющихся строк с помощью Go

Программы для копирования файлов, печати, поиска, сортировки, подсчета и другие имеют схожую структуру: цикл по входным данным, некоторые вычисления над каждым элементом и генерация вывода "на лету" или по окончании вычислений. Давайте проработем три варианта программы под названием "dup", некоего аналога uniq из Unix, которая ищет соседние повторяющиеся строки.

Вариант 1

Здесь "dup" выводит каждую строку, которая в стандартном вводе появляется больше одного раза, выводя предварительно количество ее появлений. В этой программе вводятся:

  1. инструкция if
  2. тип данных map
  3. пакет bufio

// Выводит текст каждой строки, которая появляется
// в стандартном вводе более одного раза,
// а также количество ее появлений
package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	counts := make(map[string]int)
	input := bufio.NewScanner(os.Stdin)
	for input.Scan() {
		counts[input.Text()]++
	}
	// Примечание: игнорируем потенциальные ошибки из input.Err()
	for line, n := range counts {
		if n > 1 {
			fmt.Printf("%d\t%s\n", n, line)
		}
	}
}

Отображение map содержит набор пар "ключ-значение" и обеспечивает константное время выполнения операций хранения, извлечения или проверки наличия элемента в множестве. Если провести аналогию с языком программирования php, то map в Golang - это ассоциативный массив из php. Но в Go и ключи, и значения map должнв быть одного и того же типа. Ключ может быть любого типа, лишь бы значения этого типа можно было сравнить с помощью оператора ==; распространенным примером ключа являются строки. Значение может быть любого типа. В нашем примере ключи представляют собой строки, а значения представлены типом int. Встроенная функция make создает новое пустое отображение, хотя она имеет и другие применения.

Всякий раз, когда "dup" считывает строку ввода, эта строка используется как ключ в отображении, и соответствующее значение увеличивается. Инструкция counts[input.Text()]++ эквивалентна следующим двум инструкциям:
- line := input.Text()
- counts[line] = counts[line] + 1

Если в отображении еще нет нужного нам ключа, это не проблема. Когда новая строка встречается впервые, выражение counts[line] в правой части присваивает нулевое значение новому элементу, которое для типа int равно 0.

Для вывода результатов мы вновь используем цикл по диапазону, на этот раз — по отображению counts. Как и ранее, каждая итерация дает две величины - ключ и
- значение элемента отображения для этого ключа.
Порядок обхода отображения не определен, на практике этот порядок случаен и варьируется от одного выполнения программы к другому. Это сделано преднамеренно, поскольку предотвращает написание программ, опирающихся на конкретное упорядочение, которое не гарантируется.

Далее наступает очередь пакета bufio, который помогает сделать ввод и вывод эффективным и удобным. Одной из наиболее полезных его возможностей является тип с именем Scanner, который считывает входные данные и разбивает их на строки или слова; это самый простой способ обработки ввода, который поступает построчно.

Программа использует краткое объявление переменной для создания новой переменной input, которая ссылается на bufio.Scanner: input := bufio.NewScanner(os.Stdin)

Сканер считывает стандартный ввод программы. Каждый вызов input.Scan() считывает очередную строку и удаляет завершающий символ новой строки; результат можно получить путем вызова input.Text(). Функция Scan возвращает значение true, если строка считана и доступна, и значение false, если входные данные исчерпаны.

Функция fmt.Printf выполняет форматированный вывод на основе списка выражений. Первым ее аргументом является строка формата, которая указывает, как должны быть отформатированы последующие аргументы. Формат каждого аргумента определяется символом преобразования, буквой, следующей за знаком процента.

Строка формата содержит также символы табуляции \t и новой строки \n. Строковые литералы могут содержать такие управляющие последовательности для представления символов, которые обычно невидимы на экране и не могут быть введены непосредственно. По умолчанию Printf не записывает символ новой строки. По соглашению функции форматирования, имена которых заканчиваются на f, такие как log.Printf и fmt.Errorf, используют правила форматирования fmt.Printf, тогда как функции, имена которых заканчиваются на ln, как Println, форматируют свои аргументы так, как будто используется символ преобразования %v, а за ним — символ новой строки.

Вариант 2

Многие программы считывают входные данные либо из стандартного ввода, как приведенная выше, либо из последовательности именованных файлов. Следующая версия "dup" может как выполнять чтение стандартного ввода, так и работать со списком файлов, используя os.Open для их открывания:

// Выводит текст каждой строки, которая появляется
// во входных данных более одного раза.
// Программа читает стандартный ввод или список именованных файлов
package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	counts := make(map[string]int)
	files := os.Args[1:]
	if len(files) == 0 {
		countLines(os.Stdin, counts)
	} else {
		for _, arg := range files {
			f, err := os.Open(arg)
			if err != nil {
				fmt.Fprintf(os.Stderr, "dup2: %v\n", err)
				continue
			}
			countLines(f, counts)
			f.Close()
		}
	}
	for line, n := range counts {
		if n > 1 {
			fmt.Printf("%d\t%s\n", n, line)
		}
	}
}

func countLines(f *os.File, counts map[string]int) {
	input := bufio.NewScanner(f)
	for input.Scan() {
		counts[input.Text()]++
	}
	// Примечание: игнорируем потенциальные ошибки из input.Err()
}

Функция os.Open возвращает два значения. Первое из них является открытым файлом (*os. File), который в дальнейшем читает Scanner. Второй результат — значение встроенного типа error. Если err равно специальному встроенному значению nil, файл открыт успешно. Этот файл читается, и по достижении его конца функция Close закрывает его и освобождает все связанные с ним ресурсы. С другой стороны, если значение err не равно nil, значит, что-то пошло не так. В этом случае значение ошибки описывает происшедшую неприятность.

Наша простейшая обработка ошибок выводит сообщение в стандартный поток сообщения об ошибках с помощью вызова Fprintf и символов преобразования %v (которые выводят значение любого типа в формате по умолчанию), после чего "dup" переходит к следующему файлу; инструкция continue выполняет немедленный переход к очередной итерации охватывающего цикла for.

Обратите внимание, что вызов countLines предшествует объявлению этой функции. Функции и другие объекты уровня пакета могут быть объявлены в любом порядке.

Отображение является ссылкой на структуру данных, созданную функцией make. Когда отображение передается в функцию, функция получает копию ссылки, поэтому любые изменения, которые вызываемая функция вносит в указываемую структуру данных, будут видны и с помощью исходной ссылки в вызывающем коде. В нашем примере значения, вставляемые в отображение counts функцией countLines, видны функции main.

Вариант 3

Рассмотренные выше варианты приложения "dup" работают в "потоковом" режиме, в котором входные данные считываются и разбиваются на строки по необходимости, так что в принципе эти программы могут обрабатывать произвольное количество входных данных. Альтернативный подход состоит в чтении всех входных данных в память одним большим "глотком", полном разделении его на строки, и последующей обработке строк. 3-й вариант нашей программы работает именно таким образом.

В ней вводятся функция ReadFile (из пакета io/ioutil), которая считывает все содержимое именованного файла, и функция strings.Split, которая разбивает строку на срез подстрок.

Как вы уже поняли, этот вариант программы несколько упрощен. Во-первых, этот вариант программы читает только именованные файлы, но не стандартный ввод, так как функции ReadFile требуется аргумент, который представляет собой имя файла. Во-вторых, мы перенесли подсчет строк обратно в функцию main, так как теперь он необходим только в одном месте.

package main

import (
	"fmt"
	"io/ioutil"
	"os"
	"strings"
)

func main() {
	counts := make(map[string]int)
	for _, filename := range os.Args[1:] {
		data, err := ioutil.ReadFile(filename)
		if err != nil {
			fmt.Fprintf(os.Stderr, "dup3: %v\n", err)
			continue
		}
		for _, line := range strings.Split(string(data), "\n") {
			counts[line]++
		}
	}
	for line, n := range counts {
		if n > 1 {
			fmt.Printf("%d\t%s\n", n, line)
		}
	}
}

Функция ReadFile возвращает байтовый срез, который должен быть преобразован в string так, чтобы его можно было разбить с помощью функции strings.Split.

Внутри функции bufio.Scanner, ioutil.ReadFile и ioutil.WriteFile используют методы Read и Write объекта *os. File, но большинству программистов очень редко приходится прибегать к таким низкоуровневым функциям непосредственно. Проще использовать функции более высокого уровня наподобие функций из пакетов: