Картина знакома многим. Скрипт годами исправно трудился на CentOS, его перенесли на Ubuntu - и он рассыпался с непонятными ошибками вроде жалобы на унарный оператор или ненайденную команду. Код не менялся ни на строку. Поменялась оболочка, которая его исполняет, хотя в заголовке скрипта стоит всё та же привычная строчка. Виновник - расхождение между расширениями bash и стандартом POSIX, и точкой, где это расхождение больно бьёт, чаще всего оказывается оболочка dash в Ubuntu.

Корень в том, что bash умеет гораздо больше, чем требует стандарт. Эти приятные сверх-возможности зовут bashism - конструкции, которых нет в POSIX. Пока скрипт исполняется самим bash, они работают. Но стоит запустить его под более скромной оболочкой, придерживающейся стандарта, как незнакомый ей синтаксис вызывает сбой. Разберём, почему так вышло в Ubuntu, какие именно конструкции ломаются, и как писать так, чтобы скрипт пережил переезд.

Ubuntu подменяет sh на быстрый dash, и заголовок скрипта врёт

Чтобы понять проблему, нужно увидеть одну тонкость заголовка скрипта. Первая строка вида с указанием оболочки определяет, кто будет исполнять файл. И здесь Ubuntu расставляет ловушку. Заголовок, ссылающийся на стандартную оболочку sh, во многих дистрибутивах указывает на bash. Но в Ubuntu и Debian та же ссылка ведёт на dash - компактную и быструю оболочку, которая придерживается только POSIX и не знает расширений bash.

#!/bin/sh
# В CentOS это запустит bash, в Ubuntu - dash. Один заголовок, разные оболочки.

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

Ключевой вывод сразу: заголовок с sh обещает POSIX-совместимость, а заголовок с явным указанием bash обещает расширения bash. Если скрипт использует bashism, его заголовок обязан явно называть bash. Иначе обещание расходится с содержимым, и на машине с dash наступает расплата.

#!/bin/bash
# Честный заголовок: скрипт заявляет, что ему нужен именно bash

Двойные квадратные скобки против одинарных

Самый частый bashism - расширенный условный оператор в двойных квадратных скобках. Bash добавил его как более удобную и безопасную форму проверок. В POSIX его нет вовсе, там есть только одинарные скобки, которые на деле являются командой проверки.

# Bashism: dash такого не знает
if [[ $a -lt $b ]]; then ...

# POSIX: работает везде
if [ "$a" -lt "$b" ]; then ...

Разница не только в числе скобок. Двойные скобки прощают незакавыченные переменные и не разваливаются на пустых значениях. Одинарные требуют дисциплины: переменные нужно брать в кавычки, иначе пустое значение превращает проверку в синтаксически неполную и роняет её с той самой ошибкой про унарный оператор.

# Опасно в POSIX: при пустой переменной проверка ломается
if [ $переменная = "значение" ]; then ...

# Классический приём защиты от пустоты
if [ "x$переменная" = "xзначение" ]; then ...

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

Оператор сравнения, ключевое слово source и эхо с флагами

Целая россыпь мелких bashism подстерегает в повседневных конструкциях. Двойной знак равенства для сравнения строк - расширение bash. POSIX признаёт только одинарный знак равенства внутри команды проверки.

# Bashism
[ "$a" == "$b" ]

# POSIX
[ "$a" = "$b" ]

Команда подключения другого файла через ключевое слово source - тоже bashism. POSIX для той же цели использует точку, лаконичную и переносимую.

# Bashism
source ./настройки.conf

# POSIX
. ./настройки.conf

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

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

# Ненадёжно: поведение флага разнится между оболочками
echo -e "строка один\nстрока два"

# Переносимо: printf ведёт себя одинаково
printf 'строка один\nстрока два\n'

Массивы, локальные переменные и случайные числа просто отсутствуют

Некоторые bashism не имеют POSIX-замены вовсе, потому что соответствующей возможности в стандарте нет в принципе. Массивы - яркий пример. Индексные и ассоциативные массивы целиком относятся к расширениям bash. Скрипт, опирающийся на массивы, переносимым на чистый POSIX-shell быть не может, и тут выбора нет: либо честный заголовок с bash, либо переработка логики без массивов.

То же касается ассоциативных массивов, объявления переменных как локальных внутри функций через ключевое слово local, встроенной переменной случайного числа и арифметического разворачивания в двойных скобках. Все они - удобства bash, отсутствующие в скромном POSIX-наборе.

# Всё это bashism без прямого POSIX-аналога
массив=(один два три)        # массивы
local перем="значение"       # локальные переменные в функции
число=$RANDOM                # встроенный генератор случайных
((счётчик++))                # арифметика в двойных скобках

Здесь и проходит главная развилка. Если скрипт всерьёз нуждается в массивах, локальных переменных или другой роскоши bash, попытка втиснуть его в POSIX обернётся уродливыми обходными путями. Разумнее честно объявить зависимость от bash в заголовке. Переносимость нужна не любой ценой - она оправдана для системных и установочных скриптов, которым предстоит бегать по самым разным машинам, но избыточна для скрипта, живущего в одном известном окружении.

Инструмент checkbashisms находит расхождения заранее

Ловить bashism вручную, перечитывая код глазами, утомительно и ненадёжно. Для этого есть специальный инструмент - checkbashisms. Это проверяющая программа, которая просматривает скрипт с заголовком sh и предупреждает обо всех найденных конструкциях, выходящих за рамки POSIX.

# Установка в Debian и Ubuntu
sudo apt install devscripts

# Проверка скрипта на bashism
checkbashisms мой_скрипт.sh

Инструмент укажет конкретные строки и конструкции, которые сломаются под dash, - бесценно при подготовке скрипта к переносу. Под капотом он опирается на определение bashism как возможности оболочки, не обязательной к поддержке стандартом POSIX. Рядом с ним часто применяют и более общий статический анализатор shell-скриптов, который ловит не только bashism, но и широкий круг других типичных ошибок.

Существует и простой способ проверить скрипт на практике: запустить его явно через dash и посмотреть, не посыплются ли ошибки. Если скрипт отработал под dash без сбоев, он, скорее всего, чист от bashism.

# Прямая проверка: запуск под dash вместо sh
dash мой_скрипт.sh

Как выбрать сторону осознанно

Картина складывается в ясную стратегию, и сводится она к честности заголовка. Первый вопрос - нужна ли скрипту переносимость на самом деле. Системные скрипты, установщики, код, который поедет на неизвестные машины и встроенные системы, выигрывают от строгой POSIX-совместимости, потому что dash, busybox и прочие минимальные оболочки встречаются повсюду.

Для таких скриптов держат заголовок с sh, избегают двойных скобок в пользу одинарных с обязательным закавычиванием переменных, заменяют двойной знак равенства на одинарный, source на точку, вывод с флагами на printf, и отказываются от массивов с локальными переменными. Проверку доверяют инструменту checkbashisms, а не своей памяти.

Для скриптов, живущих в известном окружении, где bash гарантированно есть, правильнее не насиловать код ради переносимости, а честно объявить зависимость заголовком с явным указанием bash. Тогда все его удобства - массивы, расширенные проверки, арифметика - доступны без оговорок, и никакой dash их не сломает, потому что исполнять скрипт будет именно bash.

Главное, что стоит унести: проблема не в самих bashism и не в dash, а в расхождении между обещанием заголовка и содержимым скрипта. Заголовок с sh, набитый расширениями bash, - это бомба замедленного действия, ждущая переезда на Ubuntu. Стоит привести заголовок в соответствие с реальными потребностями кода, и загадочные поломки при переносе исчезают. Скрипт честен ровно настолько, насколько его первая строка соответствует тому, что написано ниже.