LINUX.ORG.RU

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

 


3

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

Наконец подсказали, что во втором скрипте у меня ошибка: read -r очищает строку от пробельных символов по краям, поэтому grep её не находит и получается бесконечный цикл. Спасибо @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

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


UPD4

Дошли руки до AWK и это походу победитель на скорость в стиле unix-way (анон давал такой рецепт в треде).

file=$1
list=$(tac $file |
    grep -v '^$' |
    awk '!added[$0]++')
tac <<< $list > $file

На моём компе — девять тысячных секунды!

★★★★★

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

Не знаю и не нужно. Стараюсь писать без ошибок 🤣

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

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

Убрал ошибки в конце

file=$1
mapfile -t list < <(grep -v '^$' $file)
N=${#list[*]}
for ((i=N-1;i>0;i--)); do
    l=${list[i]}
    [ -z "$l" ] && continue
    for ((j=0;j<i;j++)); do
        [ "$l" = "${list[j]}" ] && list[j]=
    done
done
for ((i=0;i<N;i++)); do
    [ -n "${list[i]}" ] && printf '%s\n' "${list[i]}"
done > $file

Классная идея с j<i, я не додумался так обреза́ть. Однако лишняя проверка в цикле (копейка, но всё же) и беготня по пустым индексам видимо даёт прирост.

$ time bash mky history

real	0m25.108s
user	0m25.066s
sys	0m0.022s

$ time bash me history

real	0m12.427s
user	0m12.401s
sys	0m0.014s
papin-aziat ★★★★★
() автор топика
Последнее исправление: papin-aziat (всего исправлений: 1)
Ответ на: комментарий от anonymous

Класс. Вот это unix-way, а мой вариант с циклом — говно.

Я чутка поправил как мне надо.

grep -nv '^$' history_full | sort -t':' -k2 -u | sort -t':' -k1 | sed -E 's/[0-9]+://' > history

Всё чётко

$ diff <(sort -u history_full) <(sort history)
1d0
< 

У моих вариантов такой же выхлоп.

Спасибо, анон, за напоминание, что надо уже пойти и докурить фильтры.

Сортировка чуть неправильная, нечеловеческая, но и так сойдет

О чём речь?

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

Там последовательное чтение, mmap хорош для случайного доступа. Нужен ли и использует ли gnu grep mmap?

( ... ) - это субпроцесс, чтобы измерит time только этого субпроцесса

< file cmd - это «изврат», вместо «банального» cat file | cmd

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

Ну, и арифметический цикл в bash тоже не быстрый, вот для 5.1:

time ( j=1;for i in {0..1000000}; do ((j++));done; echo $j )
1000002

real    0m4.668s
user    0m4.498s
sys     0m0.170s
[kostya@hxeon ~]$ time ( j=1;for ((i=0;i<=1000000;i++));do ((j++));done; echo $j)
1000002

real    0m6.640s
user    0m6.640s
sys     0m0.000s
Можете у себя на 4.4 сравнить. То есть для bash быстрее развернуть {0..1000000} в большую строку (кучу слов) и «бежать» по ней, чем делать инкремент и арифметическое сравнение переменной i.

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

То есть для bash быстрее развернуть {0..1000000} в большую строку (кучу слов) и «бежать» по ней, чем делать инкремент и арифметическое сравнение переменной i.

И сожрать сотни мегабайт вместо пары.

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

делать инкремент и арифметическое сравнение

Это изврат для шелла. Неудивительно, что оно не оптимизировано нифига. Массивы и хэштаблицы там тоже унылые небось. И выглядит это всё как будто кот потоптался по клавиатуре. Даже перл не настолько вырвиглазен.

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

или это просто привычка так писать?

Нет. Это сделано для того, чтобы выделить, локализовать, отделить алгоритм grep | tac | awk | tac от данных. Можно просто выделить и скопировать, можно легко добавить свои фильтры, конверторы в каскад.

< file - это «изврат», чтобы заткнуть хейтеров cat file

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

Блин, отошёл и не успел поправить комментарий :-)

Вот так будет как я задумал, там только надо якорь было поставить в regex.

file=$1
var=$(grep -nv '^$' $file |
      sort -t':' -k2 -u |
      sort -t':' -k1 |
      sed -E 's/^[0-9]+://')
echo "$var" > $file
papin-aziat ★★★★★
() автор топика
Ответ на: комментарий от papin-aziat
var=$(generate-big-string)
echo "$var"

Не надо так делать, потому что:

  • командная строка имеет ограничение и большая строка не влезет в это ограничение и отвалится с ошибкой;
  • переменная c длинной строкой отжирает память.

Надо выводит сразу в файл, можно в stdout, вызвывающий сам перенаправит куда надо, дальше на обработку или в файл

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

grep -vn '^ ' .bash_history | sort -t':' -k2 -u | sort -t':' -k1 | sed -E 's/[0-9]+://'

Да, забыл спросить, ты откуда знаешь, что sort -u оставляет именно последний уникальный вариант? Методом тыка или есть логика?

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

В инете пишут, что на встроенные команды bash ограничение на длину командной строки не распространяется. Да, это сломает скрипт при переносе в другой шелл, где echo/printf не встроенные, но это проблема не bash :)

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

В man'е же написано, что остаётся первый уникальный вариант. А потом ещё строки перемешивает вторая сортировка, так как там не указано сортировать как числа.

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

Третий раз повторяю

  1. Хочу говорить про Bash (комментарий)

  2. Хочу говорить про Bash (комментарий)

  3. это вариант неправильный, криво сортирует и написан как пример «АнтиБАШ», когда используется минимум баша и максимум сторонних утилит, в противовес «чистому башу»

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

Зачем лишний $var? Только больше проблем потенциальных (например, если будет начинаться с - или ещё чего). В таких случаях лучше сразу в файл и выводить, без промежуточного хранения в переменной:

grep -nv '^$' $file \
    | sort -t':' -k2 -u \
    | sort -t':' -k1 \
    | sed -E 's/^[0-9]+://' \
    > "$file"

upd: А, тут тот же файл, с которого читается… Ну тогда так:

grep -nv '^$' $file \
    | sort -t':' -k2 -u \
    | sort -t':' -k1 \
    | sed -E 's/^[0-9]+://' \
    | sponge "$file"
CrX ★★★★★
()
Последнее исправление: CrX (всего исправлений: 2)

а как сделать что бы всякое фуфло не запоминалось, иногда бывает введешь команду и неправильно, но ее в историю сует… вот к примеру захотел обновиться и вместо sudo apt update ввел sudo update - тут же появляется сообщение что такой команды нет, но все равно попадает в историю.

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

Можно начинать команды с пробела, тогда они не попадают в историю (эта фича включается-выключается, если что). Но это не то, чего ты хочешь, как я понимаю… Но чтобы понять реализовать то, чего ты хочешь, придётся как-то формулизовать, чем «фуфло» отличается от «не фуфла».

Вообще конкретно озвученный фариант решается чем-то вроде alias up=sudo apt update. Неужели не лень каждый раз длинную команду писать, при том, что это относительно частое действие без вариаций?

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

Неужели не лень каждый раз длинную команду писать

На zsh с этим удобно, в отличие от bash. Например, я не записываю длиннющие команды qemu в скрипт, а прямо в терминале набираю первые строки qe... и стрелочками клавиатуры перебираю историю.
В принципе и в bash так можно, но это специально настраивать, а этим мало кто хочет заниматься из башистов. Но зато bash всегда на первых местах разных голосований. )

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

Класс. Вот это unix-way, а мой вариант с циклом — говно.

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

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

Неужели не лень каждый раз длинную команду писать, при том, что это относительно частое действие без вариаций?

Нафига её писать если есть поиск по истории. Можно fzf прикрутить, тогда будет вообще зашибись. Алиасы плохи тем, что их тоже надо запоминать. Когда долго не пользуешься чем-то, потом сразу и не вспомнишь какие там алиасы.

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

включается-выключается

это я вкурсе - там все включено-выключено как надо

конкретно озвученный

да это просто пример опечатки, опечатки разные бывают - от что то пропустил до не то напечатал, но у них есть одно общее - все они заканчиаются command not found… как такое не запоминать? причем в текущей сесии оно должно запоминаться, например опечатался sudo update получил закономерный command not found, стрелкой вернул введеное ранее неправильно и по быстрому исправил опечатку допечатав недопечатаное, после того как терминал закрылся - все command not found не запоминаются.

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

а этим мало кто хочет заниматься из башистов

оно из коробки сразу работает ctrl+r - по крайней мере я ничего не настраивал, дистры разные - debian, arch, void

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

да это просто пример опечатки, опечатки разные бывают

программа возвращает код возврата, только на него и можно ориентироваться говоря про успешно или неуспешно завершившийся процесс. Если вы будете отфильтровывать все команды завершившиеся с кодом отличным от 0, то вы отфильтруете и вполне валидную команду cat myfile в случае отсутсвия такого файла в текущей директории. Отловить ошибки синтаксиса баша скорее всего не выйдет, потому что (если я правильно понимаю) каждая команда это отдельный вызов eval и он вернется с тем или иным кодом.

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

Это же история ввода. Любого. Может ты там не команду напечатал, а переменной что-то присвоил. Всё это баш должен тупо сохранять, о корректности только ты сам можешь судить.

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

На zsh с этим удобно, в отличие от bash. Например, я не записываю длиннющие команды

Да, сам так делаю. НО тут sudo, а с sudo начинается относительно большое количество команд.

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

Нафига её писать если есть поиск по истории. Можно fzf прикрутить, тогда будет вообще зашибись.

Можно. И это тоже позволит решить проблему с написанием sudo update. Достаточно первый раз сделать правильно. Но и алиас тоже.

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

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

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

алиас

на все опечатки алиасы не напишешь, иногда это просто дурацкие ошибки - например удаление чего либо # sudo apt --purge autoremove имя_пакета и тут можно опечататься в любом месте, ошибиться в имени самого пакета или тупо наколбасить в запаре subo

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

кажется, это не то, чего хотел ТС

Ну окей. Как насчёт bb -io -e '(distinct *input*)'?

cat ~/.bash_history | wc -l 
1207

cat ~/.bash_history | uniq | wc -l
1134

cat ~/.bash_history | bb -io -e '(distinct *input*)' | wc -l
690

В чём профит хренячить портянки на баше, когда полно нормальных языков — непонятно.

Nervous ★★★★★
()
Последнее исправление: Nervous (всего исправлений: 3)
Ответ на: комментарий от anonymous

Идея интересная, но я не смог заставить её работать. Конкретно твой код

declare -A exists
while IFS= read -r line; do
  [[ -v exists["$line"] ]] && continue
  exists["$line"]=1
  echo "$line"
done

не работает как надо и ошибку ещё какую-то выдаёт

$ tac history_full | bash script | tac > history
script: line 5: exists["$line"]: bad array subscript

В history каких-то 134 строки, вместо ожидаемых 4000+.

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

В чём профит хренячить портянки на баше, когда полно нормальных языков — непонятно.

Не полно. Из коробки есть перл и пистон (и то не факт). На пистоне писать скрипты будет только ССЗБ. Остается перл, который трудно назвать нормальным, но он всяко лучше баша. Доустанавливать какое-то неведомое bb (или компилятор хаскеля) ради скриптов на десятки строк? Это достойно отдельной дисциплины в спец.олимпиаде для альтернативно одаренных.

anonymous
()