Tuesday, 6 July 2010

PowerShell - AWK для Windows?

В жизни программиста нередко возникает такой момент, когда нужно автоматически перелопатить много текстовой информации, извлекая из неё что-то, либо модифицируя. Программисты и администраторы Unix всегда под рукой имеют целую кучу средств. Bash предоставляет базовые возможности вроде замены подстроки; для более мощных преобразований есть sed и awk.

Для меня всегда было загадкой, что же делать бедным программистам, живущим на Windows? Конечно, всегда можно поставить cygwin и использовать те же самые bash, sed и awk. Можно сразу взять быка за рога и установить Perl или Python. Но всё это какие-то ... неродные что ли решения. Вот были бы какие-нибудь стандартные программы для таких операций. С помощью стандартного коммандного процесора cmd заниматься текстовыми преобразованиями весьма проблематично...

Итак, где же решение? Довольно давно на горизонте Windows замаячил инновационный шелл под скромным названием PowerShell. От всех остальных шеллов в мире он отличается объектной-ориентированностью и строгой динамической типизацией. Как это выглядит на практике, объяснять довольно долго, и об этом прекрасно рассказано в книге "PowerShell in Action". Получилось непривычно, но вполне эффективно. Ладно, шелл шеллом, а как быть с обработкой текстовых данных? Способен ли этот шелл заменить собой AWK для Windows? Как выяснилось, ответ положительный.

Перед нами простой AWK скрипт, который обновляет два определения в rpm .spec-файле:
awk '
$1 " " $2 == "%define name" {
    printf  "%s %- 17s %s\n", $1, $2, "'$PKG_NAME'"
    next
}
$1 " " $2 == "%define _requires" {
    printf  "%s %- 17s %s\n", $1, $2, "'$REQUIRED_PKG_NAME'"
    next
}
{
    print $0
}' $SPEC_FILE > $SPEC_FILE.new
Он не претендует на шедевральность, страдает повторами, но своё дело делает исправно. Как он работает? Awk читает $SPEC_FILE построчно и каждую строку передаёт в скрипт, предварительно разбив её на поля по пробелам. Каждое поле доступно в виде переменной вида $n, где n - число от 0 до количества полей. Так, строка "Hello World" будет разбита следующим образом: $0 = "Hello World", $1 = "Hello", $2 = "World". Сам скрипт организован в виде последовательности шаблонов и соответствующих им действий. Если шаблон соответствует текущей строке, то соответствующее действие выполняется. Действие без шаблона выполняется для каждой строки, если до него доходит управление. Шаблон в awk понятие довольно общее - это может быть регулярное выражение или условное выражение, которое может сопровождаться побочными эффектами, например, выделением подгрупп из поля с помощью регулярного выражения.

Предоположим, что у нас есть следующая спека:
# skipped 
%define name              PKG_NAME
%define _requires         REQUIRED_PKG_NAME
# skipped

После применения скрипта, полагая что
PKG_NAME=NEW_PACKAGE и REQUIRED_PKG_NAME=OLD_PACKAGE,
получим:
# skipped
%define name              NEW_PACKAGE 
%define _requires         OLD_PACKAGE 
# skipped

Как достичь аналогичного результата в PowerShell? Как оказалось, очень просто. В PowerShell есть интересный оператор switch. Может он неожиданно много. На первый взгляд, он ничем принципиально не отличается от аналогичных операторов в языках типа C. Но в PowerShell этот оператор гораздо более гибок. Нас интересуют следующие его свойства:
  1. Он может содержать в качестве шаблонов произвольные типы PowerShell, в частности строки;
  2. Он может принимать параметры (оператор, который принимает параметры, с ума сойти), которые влияют на интерпретацию шаблонов и анализируемого выражения. Мы применим:
    1. -regex: интерпретирует шаблон-строку как регулярное выражение и проверяет анализируемое значение на соответствие ему;
    2. -file: интерпретирует анализируемое выражение как имя файла, и построчно (звучит знакомо :) ) передаёт его в оператор switch.

Итак, аналогичный скрипт на PowerShell:
$(switch -regex -file ($SPEC_FILE) {
    '%define name' {
        "{0,-25} {1}" -f $matches[0], $PKG_NAME
    }
    '%define _requires' {
        "{0,-25} {1}" -f $matches[0], $REQUIRED_PKG_NAME
    }
    default {
        $_
    }
})
Он удивительно похож на исходный. Впрочем, это, скорее, не удивительно, потому что, по утверждению создателей PowerShell, они учли весь тридцатилетний опыт Unix-шеллов и опыт современных скриптовых языков как Perl и Python. Хочется отметить следующие особенности:
  1. Автоматическая переменная $matches, которая чудесным образом появляется после выполнения операции соответствия регулярному выражению. 
  2. Другая чудо-переменная: $_ - обозначает анализируемое выражение, то есть всю текущую строку в данном случае. 
  3. Получившиеся строки не печатаются (print, echo, write) и не возвращаются (return), не собираются в массив, а эмитируются (emit). Эмитирование - это неявное возвращение результата. Так, в Bash'е результат выполнения скрипта - это результат последней команды в нём, если нет явного вызова exit. В Groovy, функции возращают результат последнего выражения, если не указано слово return, и если функция не типа void. В PowerShell функция (или любой скрипт-блок как в switch) может эмитировать любое количество результатов, и возвращаются они все. По умолчанию, эмитированые значения выводятся на экран. Но мы перехватываем результат оператора switch c помощью нотации подвыражения $(<statements>). Таким образом, эмитированый результат оператора становится значением подвыражения, а с этим значением можно сделать всё что угодно - хоть переменной присвоить, хоть в файл вывести.

Выразительные возможности PowerShell и оператора switch вполне соответствуют возможностям языка AWK. Из отличий хочется прежде всего упомянуть отсутствие автоматического разбиения на поля по разделителю. Строка обрабатывается целиком. Но называть это минусом я бы не стал. По моему (впрочем, небольшому) опыту, обработка строки целиком нередко оказывается даже более удобной. Кроме того, строку всегда можно разбить на поля с помощью методов String.split или Regex.split. Не стоит забывать, что ноги у PowerShell растут напрямую из .NET. PowerShell позволяет использовать более органичный синтаксис для перехвата групп регулярного выражения, чем AWK (там пришлось бы использовать такой шаблон: match($0, /%define name/, matches)).

PS: Написав всё это, я осознал, что данная задача настолько тривиальна, что можно было бы вполне обойтись двумя заменами регулярных выражений по всему тексту файла. Тем не менее, она неплохо иллюстрирует сходства и различия подхода к обработке текста в AWK и PowerShell.

No comments: