LINUX.ORG.RU

Хочу говорить про Bash

 


2

4

Для себя уже навалял кучку полезных скриптиков и походу изучения bash периодически их перечитываю, и что-то там допиливаю, но понял, что надо что-то посложнее попробовать и этакое поближе к программированию.

Решил попробовать написать программку, которая удаляет дубликаты строк из .bash_history (там более 7000 строк у меня). Мне это показалось достойной задачкой для начинающего башиста, хотя и (может быть) довольно бесполезной. Кстати, есть такая программа shell-history-cleaner (кажется на расте написана, давно себе собрал, работает, но видимо заброшена автором), я пользуюсь, но попробовать свои силы надо было.

В результате мне удалось сделать задуманное, но хочется поговорить и о «неудачных» вариантах, которые может быть были бы более удачными, если бы я больше знал о bash и linux вообще, так что категорически приветствуется критика и подсказки более правильных решений или каких-нибудь хитростей командной строки.

Так как-то сложилось, что я упорно откладывал изучение массивов на потом, и когда наконец более-менее разобрался с индексированным массивом, то сразу приступил к делу, чтобы заодно закрепить прочитанное в книжках, поэтому первый «неудачный» вариант будет об этом.

Программа рабочая, делает дело, но долго. Мой файл перемалывает за минуту с небольшим, при этом нагружая одно виртуальное ядро процессора на 100%. Зная лоровские нарративы об ущербности bash как языка программирования, я подумал, что вот и столкнулся с подобной ущербностью, поэтому просто искал возможность хоть как-то оптимизировать процесс.

Я не знаю никаких алгоритмов, так что рассудил так: надо взять (последнюю) строку, удалить дубликаты, строку положить в файл и так по кругу.

file=$1
mapfile -t list < <(grep -v '^$' $file)
while ((${#list[*]})); do
    line=${list[-1]}
    unset list[-1\]
    for i in ${!list[*]}; do
        [[ $line == "${list[i]}" ]] &&
            unset list[i\]
    done
    final+=("$line")
done
printf '%s\n' "${final[@]}" | tac > $file

Для таких же любителей как я поясню идею кода. На его краткость отлично повлиял тот факт, что в истории командной строки надо сохранять последние уникальные строки, то есть работать надо с конца файла, а у индексированных массивов как раз есть стабильная ссылка на последний индекс: array[-1]. То есть не надо ничего переворачивать.

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

Поскольку на данном этапе я застрял и вроде бы ясно, что тема с массивами как-то всё усложняет, то решил написать скриптик в стиле unix-way, и искренне полагал, что это будет правильным решением задачки, ведь утилиты все написаны суровыми дядьками на Си, и там всё без дураков — быстро и надёжно. Пришла идея отбросить всю эту возню с поиском и удалением, а просто взять всё — и отфильтровать.

file=$1
list=$(tac $file | grep -v '^$')
while [[ $list ]]; do
    read -r line <<< $list
    list=$(grep -Fxv "$line" <<< $list)
    echo "$line"
done | tac > $file

Чтобы уважаемый лорчанин не хельпал ключи -F -x, напомню: F — читает regex буквально, а x — помещает выражение между ^$, иначе пришлось бы использовать ключ -P (perlre) и выражение выглядело бы как говно: "^\Q$line\E$", а работало бы ещё медленней, возможно, но это не точно. (кстати, я был весьма разочарован узнать, что с sed такое вообще не провернуть — никак не заставить подстановку читать буквально).

Итак, на короткой дистанции (разумеется, у меня был короткий вариант файла для тестов) этот скрипт почти в два раза обогнал предыдущий, но — что было для меня полнейшим разочарованием! — он совершенно заткнулся даже на средней дистанции (нагружая процессор на четверть, но размазано по потокам), то есть я его тупо прервал на какой-то там 10-ой минуте, так что о проверке на полном файле речи уже не шло. Как это понимать — не знаю, поясните. 1000 строк он смолол где-то за секунду с небольшим, а 3000 — уже застрял.

Ладно, пока не было новых идей, прочитал главу про ассоциативные массивы и сразу почуял, что это походу то, что мне надо, но я не ожидал, что настолько! Свойство хеша не дублировать индексы как будто специально создано для решения этой задачки. Быстро стало понятно, что надо просто переложить строки из обыкновенного массива в индексы хеша, а в значения хеша — номера индексов строк из обыкновенного массива.

file=$1
mapfile -t list < <(grep -v '^$' $file)
declare -A hash
for i in ${!list[*]}; do
    hash[${list[i]}]=$i
done
for i in "${!hash[@]}"; do
    final[${hash[$i]}]="$i"
done
printf '%s\n' "${final[@]}" > $file

Это было круто! Нет смысла даже говорить о времени выполнения этой программы, она работает почти мгновенно, менее двух десятых секунды.

Получается на bash таки можно что-то программировать и оно может работать быстро.


UPD

Наконец подсказали, что во втором скрипте у меня ошибка. Спасибо @mky: Хочу говорить про Bash (комментарий)
Теперь этот скрипт переваривает тот же файл за 15-16 секунд!

file=$1
list=$(tac $file | grep -v '^$')
while [[ $list ]]; do
    IFS=$'\n' read -r line <<< $list
    list=$(grep -Fxve "$line" <<< $list)
    echo "$line"
done | tac > $file

UPD2

Спасибо анону, подкинул идею отфильтровать хешем в один проход: Хочу говорить про Bash (комментарий)

file=$1
mapfile -t list < <(tac $file | grep -v '^$')
declare -A hash
for i in "${list[@]}"; do
    [[ ${hash[$i]} ]] && continue
    hash[$i]=added
    final+=("$i")
done
printf '%s\n' "${final[@]}" | tac > $file

UPD3

Продолжаю благодарить анона, что замотивировал таки раскурить sort. Итак, самый шустрый вариант:

file=$1
list=$(< $file \
    grep -vn '^$' |
    tac |
    sort -t: -k2 -u |
    sort -t: -k1,1n |
    cut -d: -f2-)
echo "$list" > $file

На моём компе меньше трёх сотых секунды!

★★★★★

Последнее исправление: papin-aziat (всего исправлений: 11)
Ответ на: комментарий от dmitry237

Обязательно! Просто хочу, чтобы файл пока оставался максимально всратым, чтобы на нём выбрать алгоритм-победитель по удалению дубликатов. И то вон, практика показала, что он недостаточно упорот, в нем нет ни одной строки начинающейся с - 😀

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от dmitry237

Кажется, ты не знаешь смысла, зачем надо удалять команды начинающиеся с пробела (а не менять факты в истории). В общем, уже после примера на powershell ценность твоих сообщений резко упала.

anonymous
()
Ответ на: комментарий от anonymous

зачем надо удалять команды начинающиеся с пробела

Это понятно, они не попадают в автодополнение, но вот почему в историю попадают пустые строки мне не понятны.

В общем, уже после примера на powershell ценность твоих сообщений резко упала

Ну я же должен объяснить сам принцип с файлами истории.
pwsh, это и плюсы и минусы, удобный шелл, но слишком дорогие скрипты, при запуске исполняемого скрипта с pwsh-шебангом за собой тянет рантайм, а это ~150мб (bash чуть больше 5мб).

dmitry237 ★★★★
()
Ответ на: комментарий от anonymous

У меня всё работает.

Вот этот тест не показывает двойную подстановку?

$ l=\$foo
$ foo=bar
$ declare -A hash
$ hash=([\$foo]=1)
$ [[ -v hash[$l] ]] && echo true || echo false
false
$ eval echo $l
bar
$ eval echo \$l
$foo
$ [[ -v hash[\$l] ]] && echo true
true

Какая версия баш?

GNU bash, version 4.4.20(1)-release (x86_64-redhat-linux-gnu)

.

[[ ${exists[$l]:+exists} ]] && continue

Да, я покумекал на эту тему, но пользы не увидел. В твой вариант с [[ ведь и так будет подставлено либо строка, либо NULL.

Ради того, чтобы попрактиковать «язык переменных», можно было бы вот так изогнуться

${exists[$l]+fasle} || continue

но это вряд ли потом можно прочитать без боли, да и просто выпендрёжь получается 😀

«Антибаш» …

Я ещё не добрался до этой темы, спасибо, обязательно подумаю, и, кстати, скорее всего это кандидат на победу в конкурсе на скорость.

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от papin-aziat

Вы, всё таки, сначала прочитайте bashpitfall, а уже потом ко всяким eval и арифметики с ассоциативными массивами переходите.

А то будет у вас там в истории $( rm -rf ~) и на этом ваш кодинг после выполнения:

(( ${exists[$l]} )) && continue
закончится. Если лень читать весь Pitfall, то пример 62, плюс https://unix.stackexchange.com/questions/627474/how-to-use-associative-arrays...

И, так как поведение bash с арифметикой с ассоциативными массивами меняется от версии к версии, проще просто никогда не сувать ассоциативный массив в двойные круглые скобки, чем помнить все особенности.

mky ★★★★★
()
Ответ на: комментарий от mky

так как поведение bash с арифметикой с ассоциативными массивами меняется от версии к версии, проще просто никогда не сувать ассоциативный массив в двойные круглые скобки, чем помнить все особенности

Хорошо. Просто на этапе изучения я пробую всё, но мне уже (кажется) становится понятно, что если можно работать со строкой, то наверное лучше со строкой даже если это число.

А то будет у вас там в истории $( rm -rf ~) и на этом ваш кодинг после выполнения:

(( ${exists[$l]} )) && continue

закончится.

Я подумал, что в данном случае будет либо 1, либо NULL. Не подумал бы, что эта конструкция может выполнить код, зачем?

Ну, а если пришибёт, то будет мне наука, а актуальной бекапчик системы имеется 😎

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от papin-aziat

Вот этот тест не показывает двойную подстановку?

И не должен, у анона 5.2. Одна из отличий 5.2 от 5.1:

Bash-5.2 attempts to prevent double-expansion of array subscripts under certain circumstances, especially arithmetic evaluation, by acting as if the `assoc_expand_once' shell option were set.

А assoc_expand_once (это shell options) появилась в 5.0, ЕМНИП, и она может быть включена в начале скрипта, для явного запрета двойной подстановки.

Вот вы и добрались до разного поведения баш-скриптов и разборов, почему у одного «УМВР», а у другого скрипт творит непонятно что.

mky ★★★★★
()
Ответ на: комментарий от mky

Ну ты голова! У меня, к сожалению, такой опции нет, надо подумать о новом баше

$ shopt | grep assoc || echo облом
облом

😧

papin-aziat ★★★★★
() автор топика
Последнее исправление: papin-aziat (всего исправлений: 1)
Ответ на: комментарий от mky

https://unix.stackexchange.com/questions/627474/how-to-use-associative-arrays

Спасибо за ссылку, оставлю и погоняю примеры на своём bash-4.4, так как это системный шелл, то надо знать о проблемах.

Таким образом получается, что башистам надо уметь писать переносимый код между версиями баша, — жесть! Стартовый пост подправил.

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

$ cat test
aaa

bbb
$ mapfile -t list < test
$ declare -A hash
$ for i in "${list[@]}"; do hash[$i]=1; done
bash: hash[$i]: bad array subscript
$ declare -p hash
declare -A hash=([aaa]="1" )

😱

Кстати, я узнал об ассоциативных массивах из книги «Идиомы bash» (Карл Олбинг, Джей Пи Фоссен) и там на странице 106 под номером 7 дана такая идиома в коде подсчёта слов:

(( myhash[$line]++ ))

🙂

В книге о баше-5 говорят как о будущем.

papin-aziat ★★★★★
() автор топика
Последнее исправление: papin-aziat (всего исправлений: 2)
Ответ на: комментарий от papin-aziat

С какой-то верии 5.0, 5.1 может быть и пустой индекс ассоциативного массива и из пробелов. Причём, ЕМНИП, в какой-то момент была баго-фича, что независимо от числа пробелов строка считалась за индекс из одного пробела, а потом поправили — один и два пробела — разные индексы.

переносимый код между версиями баша, — жесть!

Жесть заключается в том, что никто не знает, что поменяют. То есть в любой момент старый скрип, проработавший N+1 лет, начинает работать как-то не так, а если он большой-развесистый, то можно долго понимать, что его сломало. Например, ещё не проверял, но написано, что в 5.2 конструкция с бэкслешем ${hash[\$i]} не работает.

Конечно, можно в начале скрипта проверять $BASH_VERSION и перестовать работать, если версия неизвестная. Но на ролинг дистрах не вариант.

Может, когда-нибудь, ChatGPT или что подобное научится проверять скрипт на совместимость версий. Не всякий бред генерить с нуля, а ошибки искать в имеющимся коде. Типа дал ему скрипт, написал, что скрипт правильно работал на версии 2.05, и пусть ИИ напишет, что сломается на версии 5.2. Этим ведь не только bash страдает, куча относительно полезных скриптов на питоне 2.7.

mky ★★★★★
()
Ответ на: комментарий от anonymous

Твой не проверял, просто сделал по аналогии, — работает!

file=$1
list=$(< $file \
    grep -vn '^$' |
    tac |
    sort -t: -k2 -u |
    sort -t: -k1,1n |
    cut -d: -f2-)
echo "$list" > $file

Сравнивал результат с «эталонным» файлом, обработанным скриптом с массивами, — полное совпадение. Разумеется, это самый быстрый вариант: у меня меньше трех сотых секунды.

Другие варианты (читый баш, awk) уходят в своп, если повезет выбивает oom-киллер

Вот этого я не понял, что там и куда успевает уходить за это время 🙂

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от mky

Жесть заключается в том, что никто не знает, что поменяют. То есть в любой момент старый скрип, проработавший N+1 лет, начинает работать как-то не так, а если он большой-развесистый, то можно долго понимать, что его сломало.

Этим ведь не только bash страдает, куча относительно полезных скриптов на питоне 2.7.

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

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от papin-aziat

Вроде экономить тут не на чем, там даже не мегабайты.

Не, тут дело не в памяти, а в том, что в шелле не всегда хорошая идея хранить в переменных что-то большое и разнообразное, а потом передавать в качестве аргумента командам. Приходится помнить не только об экранировании, но и о формате, ведь команда не поймёт, где там именно строка, а где ключи, поэтому если оно с дефиса, например начинается, могут быть проблемы. Ну и читабельность это не повышает, а скорее понижает в данном случае, имхо. Цепочка из пайпов, а в конце перенаправление в файл — стандартная для шелла конструкция.

CrX ★★★★★
()
Ответ на: комментарий от CrX

Приходится помнить не только об экранировании, но и о формате, ведь команда не поймёт, где там именно строка, а где ключи, поэтому если оно с дефиса, например начинается, могут быть проблемы.

С экранированием мне более-менее понятно, хотя надо ещё более точно знать поведение некоторых команд, а вот с проблемой дефиса я наконец столкнулся на деле, надеюсь запомнится и будет всплывать в сознании.

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от papin-aziat
file=$1
list=$(< $file \
    grep -vn '^$' |
    tac |
    sort -t: -k2 -u |
    sort -t: -k1,1n |
    cut -d: -f2-)
echo "$list" > $file

в этой программе десять строк, а у меня уже голова болит понимать, что вся эта магия волшебных аргументов даёт. Будь здесь хотя бы пару сотен строк, и это было бы абсолютно нечитаемо. Сравните с чуть более многословным решением на питоне

import sys
from typing import Iterator


def _filer_uniq(file_name: str) -> Iterator[str]:
    with open(file_name) as f:
        lines = f.readlines()
        uniq = set(lines)
    for l in reversed(lines):
        if l in uniq:
            yield l
            uniq.remove(l)
            
if __name__ == '__main__':
    for l in reversed(list(_filer_uniq(sys.argv[1]))):
        print(l, end='')

в последнем понятна абсолютно любая строка - что она делает и зачем, и скрипт в целом не вызывает вопросов. А вот что делает sort -t: -k1,1n надо лезть в мануал, я не готов сразу ответить, что за магия -k1,1n

FishHook
()
Ответ на: комментарий от FishHook

Да, выглядит ужасно, и наверное лучше было бы оставить это в виде одной строки, а не пытаться изобразить какой-то порядок, а то увидит такое человек и подумает, что этот язык программирования некрасивый и я его учить не бубу 😁

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от anonymous

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

FishHook
()
Ответ на: комментарий от FishHook

исходный список надо отреверсить и тогда словать не очень помогает

Я привел функцию uniq c сохранением порядка. Твой пример слишком «обфусцированный». Намного легче воспринимается последовательность (композиция функций) reverse(uniq(reverse(lines))

anonymous
()
Ответ на: комментарий от mky

Например, ещё не проверял, но написано, что в 5.2 конструкция с бэкслешем ${hash[$i]} не работает.

Настроил песочницу с 5.2, просто собрал из тарболла с сайта GNU, но никаких патчей не накладывал (не умею).

Рабочего кода именно с такой подстановкой у меня нет, зато протестил этот вариант:

file=$1
mapfile -t list < <(tac $file | grep -v '^$')
declare -A hash
for i in "${list[@]}"; do
    [[ -v hash[\$i] ]] && continue
    hash[$i]=added
    final+=("$i")
done
printf '%s\n' "${final[@]}" | tac > $file

Не работает! Просто молча перегнал все строки (кроме пустых).

Кстати, вот так по умолчанию:

$ shopt | grep ^assoc
assoc_expand_once	off

Так и оставил.

Далее, убрал экранирование:

[[ -v hash[$i] ]] && continue

Работает! Всё чётко, хотя и с отключенным assoc_expand_once… Теоретически, не должно было ведь работать, да? Или дело не в двойной подстановке?

Конечно, можно в начале скрипта проверять $BASH_VERSION и перестовать работать, если версия неизвестная. Но на ролинг дистрах не вариант.

Вернул экранирование:

[[ -v hash[\$i] ]] && continue

Добавил в начало скрипта:

shopt -s compat44

Работает!

Получается можно до посинения сидеть на 4.4, а потом в рабочие скрипты добавить совместимость. Было бы круто, если бы это вот так просто работало.

papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от mky

А то будет у вас там в истории $( rm -rf ~) и на этом ваш кодинг после выполнения:

(( ${exists[$l]} )) && continue

закончится.

Надо закрыть по этому вопросу. Здесь нет проблем, видимо в конструкцию ${} оболочка не лезет и подстановкой занимается только [].

bash-4.4

$ > foo
$ line='$(rm foo)'
$ declare -A assoc
$ (( ${assoc[$line]} )) || assoc[$line]=1
$ echo ${assoc[$line]}
1
$ ls foo
foo
$ [[ ${assoc[$line]} ]] && assoc[$line]=2
$ echo ${assoc[$line]}
2
$ ls foo
foo

А вот это да:

$ [[ -v assoc[$line] ]]
bash: assoc: bad array subscript
$ ls foo
ls: cannot access 'foo': No such file or directory
$ > foo
$ (( assoc[$line]++ ))
bash: assoc: bad array subscript
rm: cannot remove 'foo': No such file or directory
bash: assoc[$(rm foo)]: bad array subscript

Инкремент совсем какой-то жёсткий вариант, там получается даже два раза rm отрабатывает что ли?

Ладно, в 5.2 это починили, а вот чтобы такую переменную убить, в обоих версиях (для надёжности) надо ломать не только [], которая может оказаться диапазоном символов

unset assoc[$line\]

но и подстановку переменной, так как она может содержать пробелы и получаются несуществующие варианты переменных массива

unset assoc[\$line\]

Причем в 4.4 ругается

$ unset assoc[$line\]
bash: unset: `assoc[$(rm': not a valid identifier
bash: unset: `foo)]': not a valid identifier

а в 5.2 просто тихо ничего не делает, почему-то, наверное настройки какой-нибудь не хватает.

papin-aziat ★★★★★
() автор топика