statt String-Hacks und Leerzeichen-Fallen
String-Hacks in Bash – Dateilisten als Leerzeichen-getrennte Strings, Konfigurationswerte als kommaseparierte Felder – brechen systematisch, sobald Werte Sonderzeichen enthalten. Arrays in Bash sind die saubere Alternative: indexed Arrays für geordnete Listen, assoziative Arrays für Key-Value-Strukturen, Slices für Teilmengen und sichere Iteration für jeden Anwendungsfall.
Inhaltsverzeichnis
- 1. Das String-Hack-Problem in der Praxis
- 2. Indexed Arrays: Grundlagen und Deklaration
- 3. Arrays befüllen: Literal, Schleife und find
- 4. Korrekte Iteration: [@] vs [*] und Quoting-Regeln
- 5. Array-Slices, Länge, letztes Element und Subarrays
- 6. Arrays manipulieren: Anhängen, Entfernen, Sortieren
- 7. Assoziative Arrays: Key-Value ohne externe Tools
- 8. Arrays an Funktionen übergeben und zurückgeben
- 9. String-Hacks vs. Arrays im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das String-Hack-Problem in der Praxis
Der klassische String-Hack in Bash-Skripten sieht harmlos aus: FILES=$(find /var/log -name "*.log") speichert Ergebnisse in einer Variable, und for f in $FILES iteriert darüber. Das funktioniert zuverlässig genau so lange, wie kein Dateiname ein Leerzeichen, einen Tab, einen Zeilenumbruch oder ein Glob-Zeichen enthält. Sobald einer dieser Fälle auftritt – und in produktiven Umgebungen tritt er irgendwann auf – bricht das Skript auf unvorhersehbare Weise. Schlimmer noch: es bricht oft nicht mit einem Fehler, sondern mit stillem, falschen Verhalten.
Der zweite häufige String-Hack ist das Speichern von Konfigurationswerten als kommaseparierte oder leerzeichen-getrennte Strings: SERVICES="nginx mysql redis". Das erscheint einfach, bis ein Service-Name einen Bindestrich oder eine Versions-Nummer enthält und die Trennung plötzlich mehrdeutig wird. Arrays in Bash lösen dieses Problem fundamental: jedes Element wird separat gespeichert, ohne Trennzeichen-Logik, und korrekt übergeben, solange man "${array[@]}" mit doppelten Anführungszeichen schreibt.
Das mentale Modell für den Umstieg von String-Hacks auf Bash-Arrays: eine Variable ist ein einzelner Wert, ein Array ist eine geordnete Liste von Werten. Wann immer man in der Beschreibung des Problems das Wort "Liste" oder "mehrere" verwendet, ist ein Array in Bash die korrekte Datenstruktur. Das gilt für Dateilisten, Dienst-Namen, IP-Adressen, Validierungsregeln, Kommandozeilen-Argumente und Konfigurationsschlüssel gleichermassen.
2. Indexed Arrays: Grundlagen und Deklaration
Indexed Arrays in Bash sind nullbasierte Listen von Strings. Die Deklaration mit declare -a name ist optional aber explizit – ohne declare -a wird Bash beim ersten Zuweisungsversuch ebenfalls ein Array anlegen. Die Syntax array=(elem1 elem2 elem3) initialisiert ein Array mit Literalwerten. Einzelne Elemente werden mit array[0]="wert" gesetzt und mit ${array[0]} gelesen. Der Zugriff auf alle Elemente erfolgt mit ${array[@]}, die Anzahl der Elemente mit ${#array[@]}. Diese vier Grundoperationen reichen für die meisten Anwendungsfälle.
Ein wichtiger Unterschied zwischen Bash-Arrays und Arrays in Programmiersprachen: Bash-Arrays müssen nicht dicht befüllt sein. Es ist völlig legitim, Index 0, 5 und 100 zu belegen ohne die Zwischenwerte. Das führt zu unerwarteten Ergebnissen beim Iterieren über numerische Indizes, wenn man annimmt, dass sie lückenlos von 0 bis n-1 laufen. Das sicherste Muster ist immer die Element-Iteration mit "${array[@]}" statt eine Index-Schleife von 0 bis ${#array[@]}, weil letztere bei Lücken die falschen Indizes anspricht.
#!/usr/bin/env bash
# arrays_basics.sh — indexed array fundamentals
set -euo pipefail
# Declaration and initialization
declare -a services=("nginx" "mysql" "redis" "php-fpm")
# Read single element
echo "First service: ${services[0]}"
# Number of elements — no subshell needed
echo "Total services: ${#services[@]}"
# Last element (Bash 4.2+)
echo "Last service: ${services[-1]}"
# All indices (not values) — useful for sparse arrays
echo "Indices: ${!services[@]}"
# Append to array
services+=("memcached")
echo "After append: ${#services[@]} services"
# Slice: elements 1 and 2 (offset=1, length=2)
slice=("${services[@]:1:2}")
echo "Slice [1:2]: ${slice[*]}"
# Unset a single element — array becomes sparse
unset 'services[2]'
echo "After unset index 2: ${#services[@]} elements"
# Safe iteration — always double-quote [@]
for svc in "${services[@]}"; do
echo " -> $svc"
done
3. Arrays befüllen: Literal, Schleife und find
Das sicherste Muster zum Befüllen eines Bash-Arrays mit Dateipfaden ist die Kombination aus find -print0 und einer while IFS= read -r -d '' f-Schleife. -print0 trennt Dateinamen durch das Null-Byte statt durch Newlines – das einzige Zeichen, das in einem Dateipfad unter Linux nicht vorkommen kann. read -d '' liest bis zum nächsten Null-Byte, -r verhindert Backslash-Interpretation, IFS= verhindert Whitespace-Trimming. Jeder Dateiname landet als eigenes Element im Array, korrekt – unabhängig von Sonderzeichen.
Eine weitere wichtige Befüll-Methode für Arrays in Bash: mapfile (Synonym: readarray), verfügbar seit Bash 4. mapfile -t array < <(befehl) liest jede Zeile der Ausgabe eines Befehls als ein Array-Element und entfernt den nachfolgenden Zeilenumbruch durch -t. Das ist kompakter als die while-Schleife, aber hat denselben Nachteil: Newlines im Dateinamen werden als Trenner interpretiert. Für Dateilisten mit potenziell problematischen Namen ist die find -print0-Methode weiterhin die sicherste Wahl. Für normale Befehlsausgaben wie git branch --list oder Zeilen einer Konfigurationsdatei ist mapfile -t die kompakteste Lösung.
4. Korrekte Iteration: [@] vs [*] und Quoting-Regeln
Das kritischste Detail bei der Arbeit mit Bash-Arrays ist der Unterschied zwischen "${array[@]}" und "${array[*]}". Mit doppelten Anführungszeichen expandiert @ das Array in separate, einzeln quotierte Elemente – jedes Element ist ein eigenes Argument, Leerzeichen im Elementwert werden korrekt beibehalten. Mit * dagegen werden alle Elemente zu einem einzigen String zusammengeführt, getrennt durch das erste Zeichen von IFS. Der Unterschied ist nur bei Elementen mit Leerzeichen sichtbar, aber dann fatal: for f in "${array[*]}" iteriert genau einmal über alle Elemente als einen String statt n-mal über n Elemente.
Ohne Anführungszeichen ist das Verhalten noch problematischer: for f in ${array[@]} unterliegt Word Splitting und Glob-Expansion. Ein Element "file name.txt" wird zu zwei Schleifeniterationen, ein Element "*.log" wird durch Glob-Expansion zu einer Liste von Dateien im aktuellen Verzeichnis. Das ist der Kern des String-Hack-Problems – und der Grund, warum "${array[@]}" mit doppelten Anführungszeichen das einzige korrekte Muster für die Iteration über Arrays in Bash ist.
#!/usr/bin/env bash
# iteration.sh — safe array population and iteration
set -euo pipefail
# CORRECT: null-delimited find — handles all special characters
declare -a log_files=()
while IFS= read -r -d '' f; do
log_files+=("$f")
done < <(find /var/log -maxdepth 2 -name "*.log" -print0 2>/dev/null)
echo "Found ${#log_files[@]} log files"
# CORRECT: mapfile for normal line-based output (no filenames with newlines)
declare -a branches=()
mapfile -t branches < <(git branch --list 2>/dev/null | sed 's/^[* ] //')
echo "Git branches: ${#branches[@]}"
# CORRECT: iterate with double-quoted [@] — always
for f in "${log_files[@]}"; do
size=$(stat --format="%s" "$f" 2>/dev/null || echo 0)
echo " $f ($size bytes)"
done
# WRONG (for illustration — do NOT use):
# for f in ${log_files[*]} — splits on IFS, glob-expands
# for f in "${log_files[*]}" — one giant string instead of N elements
# Passing array to command — each element as separate argument
if [[ ${#log_files[@]} -gt 0 ]]; then
ls -lh "${log_files[@]}"
fi
5. Array-Slices, Länge, letztes Element und Subarrays
Die Slice-Syntax für Bash-Arrays folgt demselben Prinzip wie die Substring-Expansion für Strings: ${array[@]:offset:length} gibt length Elemente ab Position offset zurück. Das Ergebnis ist eine Liste von Elementen, die man in ein neues Array zuweisen kann: sub=("${array[@]:2:5}"). Lässt man length weg, erhält man alle Elemente von offset bis zum Ende. Das Muster ${array[@]: -1} (Leerzeichen vor dem Minus ist Pflicht, damit Bash es nicht als Parameter-Expansion für den Default-Wert interpretiert) liefert das letzte Element in Bash vor 4.2. Ab Bash 4.2 geht auch ${array[-1]} direkt.
Batch-Verarbeitung mit Array-Slices ist ein mächtiges Muster für ressourcenintensive Operationen: statt alle Elemente gleichzeitig zu verarbeiten, nimmt man immer eine feste Anzahl (batch_size=10) und iteriert mit einer for ((i=0; i<${#arr[@]}; i+=batch_size))-Schleife. In jeder Iteration extrahiert man den aktuellen Batch als Subarray und verarbeitet ihn. Dieses Muster schützt vor der Erschöpfung von Ressourcen bei sehr großen Listen und ermöglicht Fortschrittsanzeigen zwischen den Batches.
6. Arrays manipulieren: Anhängen, Entfernen, Sortieren
Das Anhängen an ein Bash-Array ist mit array+=(neues_element) eine einzelne Operation. Das Entfernen eines Elements ist weniger direkt: unset 'array[index]' entfernt das Element, lässt aber eine Lücke im Array zurück. Wer ein dichtes Array ohne Lücken braucht, muss das Array nach dem Entfernen neu aufbauen: array=("${array[@]/zu_entfernendes_element/}") entfernt alle Vorkommen eines Wertes durch leere Substitution, lässt aber leere Elemente zurück. Sauberer ist ein Filter-Muster mit einer Schleife, die alle Elemente außer dem zu entfernenden in ein neues Array kopiert.
Sortieren von Bash-Arrays erfordert einen Umweg über externe Tools, da Bash keine eingebaute Sort-Funktion für Arrays hat. Das Standardmuster: mapfile -t sorted < <(printf '%s\n' "${array[@]}" | sort) gibt alle Elemente an sort weiter und liest das Ergebnis zeilenbasiert zurück in ein neues Array. Für numerische Sortierung: sort -n. Für Rückwärtssortierung: sort -r. Deduplizierung: sort -u. Das Muster ist einfach und effizient – und man muss kein eigenes Sortieralgorithmus in Bash implementieren.
#!/usr/bin/env bash
# manipulation.sh — array manipulation patterns
set -euo pipefail
declare -a servers=("web01" "web02" "db01" "cache01" "web03")
# Append
servers+=("monitor01")
echo "After append: ${servers[*]}"
# Filter: remove all "web*" entries (rebuild without matching elements)
declare -a non_web=()
for s in "${servers[@]}"; do
[[ "$s" != web* ]] && non_web+=("$s")
done
echo "Non-web: ${non_web[*]}"
# Deduplicate while preserving order
declare -A seen=()
declare -a unique=()
for s in "${servers[@]}"; do
[[ -v seen["$s"] ]] && continue
seen["$s"]=1
unique+=("$s")
done
echo "Unique count: ${#unique[@]}"
# Sort array
declare -a sorted=()
mapfile -t sorted < <(printf '%s\n' "${servers[@]}" | sort)
echo "Sorted: ${sorted[*]}"
# Reverse sort numerically
declare -a numbers=(42 7 19 3 100 55)
declare -a num_sorted=()
mapfile -t num_sorted < <(printf '%d\n' "${numbers[@]}" | sort -rn)
echo "Numeric reverse: ${num_sorted[*]}"
# Find index of an element
target="db01"
for i in "${!servers[@]}"; do
[[ "${servers[$i]}" == "$target" ]] && echo "Found '$target' at index $i"
done
7. Assoziative Arrays: Key-Value ohne externe Tools
Assoziative Arrays in Bash (seit Bash 4, deklariert mit declare -A) sind Key-Value-Strukturen, bei denen die Schlüssel beliebige Strings sein können. Sie ersetzen eine Klasse von Shell-Skripten, die vorher auf externe Tools wie awk, sed oder sogar temporäre Dateien angewiesen waren, um Key-Value-Paare zu verwalten. Die Syntax declare -A config=([host]="localhost" [port]="3306") initialisiert ein assoziatives Array. Zugriff über ${config[host]}, alle Schlüssel über ${!config[@]}, alle Werte über ${config[@]}.
Ein praktischer Anwendungsfall für assoziative Bash-Arrays: Zähler für Häufigkeiten. Statt externe Werkzeuge zu bemühen, kann man Vorkommnisse direkt in einem assoziativen Array zählen: ((counter["$key"]++)). Das funktioniert für Log-Analyse, Statistiken über Deployment-Ergebnisse oder Häufigkeitsverteilungen von Konfigurationswerten. Ein zweiter Anwendungsfall: Lookup-Tabellen für schnellen Mitgliedschaftstest. [[ -v seen["$element"] ]] prüft, ob ein Schlüssel existiert, in O(1) statt durch lineare Suche in einem indexed Array.
#!/usr/bin/env bash
# assoc_arrays.sh — associative array patterns
set -euo pipefail
# Database configuration as associative array
declare -A db=(
[host]="db.internal"
[port]="3306"
[name]="magento"
[user]="app_user"
)
echo "Connecting to ${db[host]}:${db[port]}/${db[name]}"
# Check if key exists
if [[ -v db[password] ]]; then
echo "Password configured"
else
echo "[WARN] No database password set — check env variables"
fi
# Iterate over all key-value pairs
echo "--- Database config ---"
for key in "${!db[@]}"; do
printf " %-12s = %s\n" "$key" "${db[$key]}"
done
# Frequency counter — count log levels in a file
declare -A level_count=()
while IFS= read -r line; do
for level in ERROR WARNING INFO DEBUG; do
if [[ "$line" == *"[$level]"* ]]; then
((level_count["$level"]++)) || true
break
fi
done
done < /var/log/app.log 2>/dev/null || true
echo "--- Log level frequencies ---"
for level in ERROR WARNING INFO DEBUG; do
printf " %-10s %d\n" "$level" "${level_count[$level]:-0}"
done
# Lookup table for environment-specific config
declare -A deploy_host=(
[dev]="dev.mironsoft.local"
[staging]="staging.mironsoft.de"
[prod]="mironsoft.de"
)
ENV="${DEPLOY_ENV:-dev}"
echo "Target host: ${deploy_host[$ENV]:-unknown}"
8. Arrays an Funktionen übergeben und zurückgeben
Das Übergeben von Arrays in Bash an Funktionen ist eines der Themen, das in vielen Anleitungen falsch erklärt wird. Bash-Arrays können nicht direkt wie in Programmiersprachen als Parameter übergeben werden – myfunc "${my_array}" übergibt nur das erste Element. Das korrekte Muster ist myfunc "${my_array[@]}", wobei die Funktion die Elemente als "$@" empfängt und intern in ein lokales Array kopiert: local -a items=("$@"). Das funktioniert, solange die Funktion genau ein Array entgegennimmt.
Für das Zurückgeben von Arrays aus Bash-Funktionen gibt es zwei Muster. Erstens: die Funktion gibt Werte via echo aus und der Aufrufer fängt sie mit mapfile -t result < <(myfunc) auf – einfach, aber erzeugt eine Subshell. Zweitens: Namenreferenzen (local -n outarray="$1", Bash 4.3+) – die Funktion erhält den Variablennamen als ersten Parameter und schreibt direkt in das Array des Aufrufers. Das zweite Muster ist effizienter, erfordert aber Bash 4.3 und sorgfältiges Naming (kein Namenskonflikt zwischen Namenreferenz und lokalem Parameter).
9. String-Hacks vs. Arrays im direkten Vergleich
Der Vergleich zeigt deutlich, warum Arrays in Bash String-Hacks in nahezu jedem Anwendungsfall überlegen sind.
| Aufgabe | String-Hack (unsicher) | Array (korrekt) | Vorteil |
|---|---|---|---|
| Dateiliste | FILES=$(find …) |
find -print0 | read -d'' |
Sonderzeichen und Leerzeichen sicher |
| Dienst-Liste | SVCS="nginx mysql" |
declare -a svcs=(…) |
Saubere Iteration, keine Trennzeichen-Logik |
| Key-Value | CONFIG="host=db:port=3306" |
declare -A cfg=([host]=db) |
Direkter Zugriff per Schlüssel, keine Parsing-Logik |
| Deduplizierung | echo "$str" | sort -u |
declare -A seen; [[ -v seen[$k] ]] |
O(1) Lookup, kein Kindprozess |
| Batch-Verarbeitung | Kaum möglich ohne tmpfile | ${array[@]:i:batch_size} |
Slice-Syntax, kein Hilfsprogramm nötig |
In der Praxis ist der häufigste Grund, warum Entwickler trotzdem auf String-Hacks zurückgreifen, die Gewohnheit und die vermeintlich kompaktere Schreibweise. Tatsächlich ist die Zeile mapfile -t arr < <(befehl) nicht länger als VAR=$(befehl) – aber korrekt. Mit set -euo pipefail und ShellCheck werden String-Hack-Muster als Warnungen markiert, was den Umstieg auf Bash-Arrays unterstützt.
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Bash-Skripte mit fragilen String-Hacks ersetzen?
Wir analysieren bestehende Shell-Skripte auf String-Hack-Muster, ersetzen sie durch robuste Array-basierte Lösungen und integrieren ShellCheck in eure Pipeline – damit Sonderzeichen nie wieder ein Deployment stoppen.
Array-Refactoring
String-Hacks durch indexed und assoziative Arrays ersetzen
ShellCheck-Integration
Statische Analyse in CI einbauen und bestehende Warnungen beheben
Code-Review
Manuelle Prüfung auf versteckte Quoting- und Array-Fehler
10. Zusammenfassung
Arrays in Bash praktisch nutzen statt String-Hacks bedeutet: die Datenstruktur wählen, die dem Problem entspricht. Indexed Arrays für geordnete Listen von Elementen, assoziative Arrays für Key-Value-Strukturen, Array-Slices für Teilmengen. Das Schlüsselmuster ist immer "${array[@]}" mit doppelten Anführungszeichen – das ist der einzige Unterschied zwischen einem Skript, das mit Sonderzeichen funktioniert, und einem, das bricht. find -print0 mit read -r -d '' ist das Standardmuster für Dateilisten. mapfile -t für zeilenbasierte Ausgaben.
Assoziative Arrays seit Bash 4 sind oft die eleganteste Lösung für Probleme, bei denen man früher awk, temporäre Dateien oder komplexe String-Parsing-Logik gebraucht hätte. Die häufigsten Anwendungsfälle: Lookup-Tabellen für schnellen O(1)-Mitgliedschaftstest, Zähler für Häufigkeiten und Konfigurationsobjekte mit benannten Feldern. Mit ShellCheck in der CI-Pipeline werden String-Hack-Muster automatisch als Warnungen markiert, was die schrittweise Migration auf saubere Array-Muster in Bash systematisch unterstützt.
Arrays in Bash nutzen — Das Wichtigste auf einen Blick
Indexed Arrays
find -print0 | read -r -d '' befüllt sicher. Immer "${array[@]}" mit doppelten Anführungszeichen iterieren. ${array[@]:offset:len} für Slices.
Assoziative Arrays
declare -A (Bash 4+). Schlüssel mit ${!arr[@]}, Werte mit ${arr[@]}. [[ -v arr[key] ]] für O(1)-Existenzprüfung.
[@] vs [*]
@ mit doppelten Anführungszeichen: separate quotierte Elemente. * fasst alles zu einem String zusammen. Für Iteration: immer "${array[@]}".
mapfile
mapfile -t arr < <(befehl) — kompakt und korrekt für zeilenbasierte Ausgaben. Kein manuelles while-read nötig. -t entfernt den Zeilenumbruch.