Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.4. Модель конвейера

Для однонаправленной межпроцессной коммуникации на практике широко применяют перенаправление ввода/вывода и оператор конвейера |, при помощи которого результат работы одного процесса из его стандартного вывода (stdout) перенаправляется в стандартный ввод (stdin) другого процесса [8].

Рассмотрим ряд простых примеров использования конвейера. Например, при помощи утилиты head и оператора конвейера | легко получить первые 2 строки из файла /etc/passwd, содержащего список пользовательских учётных записей в Linux:

~$ cat /etc/passwd | head -n 2
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin

Утилита cat направляет содержимое указанного файла в stdout. При помощи оператора конвейера | результат работы cat из stdout направляется в stdin утилиты head, которая, в свою очередь, направляет в stdout только первые 2 полученные строки, после чего завершает выполнение.

Для поиска по образцу можно воспользоваться утилитой grep:

~$ cat /etc/passwd | grep /usr | head -n 2
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

Теперь в вывод включены только те строки, которые содержат заданную подстроку /usr.

Оператор конвейера, помимо командной оболочки Linux, существует и в некоторых языках программирования общего назначения, таких как Elm [9] и F# [10]. Кроме того, в 2021 году рабочей группой TC39 ассоциации по стандартизации Ecma International было предложено добавить поддержку оператора конвейера в новую версию языка JavaScript, используемого для программирования веб-приложений [11].

Модель конвейера несложно реализовать и в Python, используя перегрузку оператора |.

1.4.1. Простая модель конвейера

Сначала попробуем воспроизвести рассмотренный пример работы с конвейером в командной оболочке Linux на Python при помощи цепочки вызовов простых функций, принимающих на вход список строк stdin и возвращающих новый список строк:

def cat(path):
    # Получим список строк из файла, доступного по пути path.
    with open(path, 'r', encoding='utf-8') as file:
        return file.readlines()

def head(n, stdin):
    # Воспользуемся срезом для получения n элементов из списка stdin.
    return stdin[:n]

def grep(pat, stdin):
    # Создадим новый список из строк, содержащих pat.
    stdout = []
    for line in stdin:
        if pat in line:
            stout.append(line)
    return stdout

stdout = head(2, grep('/usr', cat('/etc/passwd')))
print(*stdout, sep='', end='')

Результатом работы функции cat является список строк. Полученный из файла /etc/passwd список строк подаётся на вход функции grep, которая возвращает только строки, содержащие подстроку /usr. Результат работы функции grep затем подаётся на вход функции head, возвращающей 2 первых элемента списка. Функция print выводит список строк в stdout.

Поместим наш код в файл pipe.py и попробуем запустить программу:

~$ python pipe.py 
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

Для добавления поддержки оператора конвейера | в Python реализуем класс Pipe с перегрузкой оператора побитового «или», а также обновим наши функции так, чтобы они не выполняли вычисления в момент их вызова, а возвращали новые функции, которые будут вызваны при применении к экземпляру класса Pipe оператора конвейера | слева:

class Pipe:
    def __init__(self, fun):
        self.fun = fun

    # Переопределим в классе метод __ror__, вызываемый при
    # использовании оператора | слева от экземпляра класса Pipe.
    def __ror__(self, lhs):
        return self.fun(lhs)

def cat(path):
    with open(path, 'r', encoding='utf-8') as file:
        return file.readlines()

# Обновим реализацию функций head и grep.
def head(n):
    def cmd(stdin):
        return stdin[:n]
    # В момент вызова функции head создадим экземпляр класса Pipe.
    # Передадим в него ссылку на локальную функцию cmd.
    return Pipe(cmd)

def grep(pat):
    def cmd(stdin):
        stdout = []
        for line in stdin:
            if pat in line:
                stout.append(line)
        return stdout
    return Pipe(cmd)

stdout = cat('/etc/passwd') | grep('/usr') | head(2)
print(*stdout, sep='', end='')

Сравним результат работы модели конвейера с командной оболочкой Linux:

~$ cat /etc/passwd | grep /usr | head -n 2
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
~$ python pipe.py 
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

Вывод программы совпадает с выводом утилит cat, grep и head, объединённых оператором конвейера в командной оболочке Linux. Расширим полученную модель конвейера, добавив поддержку сортировки с использованием стандартной функции sorted:

sort = Pipe(sorted)
stdout = cat('/etc/passwd') | grep('/usr') | head(3) | sort
print(*stdout, sep='', end='')

Убедимся, что полученная программа работает корректно:

~$ cat /etc/passwd | grep /usr | head -n 3 | sort
bin:x:2:2:bin:/bin:/usr/sbin/nologin
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
~$ python pipe.py 
bin:x:2:2:bin:/bin:/usr/sbin/nologin
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin

Полученная реализация оператора конвейера для Python, несмотря на простоту и заметное синтаксическое сходство с конвейером из командной оболочки Linux, не лишена недостатков, которые мы попробуем устранить в сопрограммной модели конвейера.

1.4.2. Сопрограммная модель конвейера

В предыдущей реализации модели все функции, которые могут использоваться вместе с конвейером |, должны быть реализованы по приведенному выше шаблону – эти функции обязательно возвращают новый экземпляр класса Pipe, в который передана ссылка на вложенную функцию, выполняющую вычисления.

Кроме того, в Linux выполнение объединенных при помощи конвейера процессов является сопрограммным – при получении данных из stdout первого процесса через stdin второй процесс сразу же начинает их обрабатывать, а в нашей реализации конвейера на Python списки строк обрабатываются функциями сразу целиком, функции выполняются не сопрограммно, а последовательно.

Первое замечание легко исправить, реализовав декоратор [12], позволяющий «превращать» обычные Python-функции в экземпляры класса Pipe, совместимые с оператором |. А для того, чтобы реализовать сопрограммную обработку строк, достаточно работу со списками заменить на работу с генераторами [13] – в этом случае каждая функция, являющаяся частью конвейера, не будет дожидаться обработки списка строк целиком, обработка элементов последовательности будет проводиться по мере получения новой строки от предыдущей функции.

Оставим без изменений определенный ранее класс Pipe с перегрузкой оператора |, добавим декоратор pipe и перепишем функции cat, head и grep, упростив их реализацию:

from itertools import islice

class Pipe:
    def __init__(self, fun):
        self.fun = fun

    def __ror__(self, lhs):
        return self.fun(lhs)

# Декоратор pipe, принимающий на вход функцию fun и
# возвращающий новую функцию, «оборачивающую» fun.
def pipe(fun):
    def cmd(*args):
        def read(stdin):
            return fun(stdin, *args)
        return Pipe(read)
    return cmd

def cat(path):
    with open(path, 'r', encoding='utf-8') as file:
        for line in file:
            yield line

@pipe
def head(stdin, n):
    return islice(stdin, n)

@pipe
def grep(stdin, pat):
    for line in stdin:
        if pat in line:
            yield line

sort = Pipe(sorted)
stdout = cat('/etc/passwd') | grep('/usr') | head(3) | sort
print(*stdout, sep='', end='')

Конструкция *args в списке параметров позволяет создать функцию, принимающую на вход произвольное число позиционных аргументов [12]. Функции cat, head и grep теперь возвращают не списки строк, а генераторы. Это достигается за счёт использования внутри каждой из этих функций оператора yield [13]. Реализация функции sort при этом осталась без изменений.

Добавим в нашу модель конвейера поддержку стандартной утилиты командной оболочки Linux tr и воспользуемся новой функцией, чтобы извлечь абсолютные пути из полученного ранее результата анализа файла /etc/passwd:

@pipe
def tr(stdin, old, new):
    for line in stdin:
        for pt in line.replace(old, new).splitlines(True):
            yield pt

stdout = cat('/etc/passwd') | grep('/usr') | head(3) | tr(':', '\n') | grep('/') | sort
print(*stdout, sep='', end='')

Сравним вывод нашей Python-программы с результатами, полученными в командной оболочке Linux:

~$ cat /etc/passwd | grep /usr | head -n 3 | tr ':' '\n' | grep / | sort
/bin
/dev
/usr/sbin
/usr/sbin/nologin
/usr/sbin/nologin
/usr/sbin/nologin
~$ python pipe.py 
/bin
/dev
/usr/sbin
/usr/sbin/nologin
/usr/sbin/nologin
/usr/sbin/nologin

1.4.3. Упражнения

Задача 1. Реализуйте поддержку команд uniq, wc, cut, curl.

Задача 2. Добавьте в модельный вариант grep поддержку регулярных выражений и опции -o. В частности, при помощи доработанной версии grep должно быть можно реализовать вывод имён пользователей, как в Linux: cat /etc/passwd | grep -E -o '^[A-Za-z_0-9-]+'

Задача 3. Реализуйте перенаправление ввода/вывода.

Задача 4. Реализуйте поддержку запуска внешних процессов в виде pipe-функций.

Задача 5. Создайте визуализатор конвейера с использованием инструмента Graphviz [14].