羽夏 Bash 簡明教程(下)

寂靜的羽夏發表於2022-05-14

寫在前面

  該文章根據 the unix workbench 中的 Bash Programming 進行漢化處理並作出自己的整理,並參考 Bash 指令碼教程BashPitfalls 相關內容進行補充修正。一是我對 Bash 的學習記錄,二是對大家學習 Bash 有更好的幫助。如對該博文有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閒錢,可以打賞支援我的創作。如想轉載,請把我的轉載資訊附在文章後面,並宣告我的個人資訊和本人部落格地址即可,但必須事先通知我。本篇博文可能比較冗長,請耐心閱讀和學習。

陣列

內容講解

  Bash 中的陣列是有序的值列表。通過將列表指定給變數名,可以從頭開始建立列表。列表是用括號(())建立的,括號中用空格分隔列表中的每個元素。讓我們列出幾個動物的陣列:

animals=(cat dog butterfly fish bird goose cow chick goat pig)

  要檢索陣列,需要使用引數展開,其中包括美元符號和花括號${}。陣列中元素的位置從零開始編號。要獲取此陣列的第一個元素,請使用${animals[0]},如下所示:

wingsummer@wingsummer-PC ~ → echo ${animals[0]}
cat

  請注意,第一個元素的索引為0。可以通過這種方式獲取任意元素,例如第四個元素:

wingsummer@wingsummer-PC ~ → echo ${animals[3]}
fish

  要獲得改動物列表的所有元素,請在方括號之間使用星號*

wingsummer@wingsummer-PC ~ → echo ${animals[*]}
cat dog butterfly fish bird goose cow chick goat pig

  還可以通過使用方括號指定其索引來更改陣列中的單個元素:

wingsummer@wingsummer-PC ~ → echo ${animals[*]}
cat dog butterfly fish bird goose cow chick goat pig
wingsummer@wingsummer-PC ~ → animals[4]=ant
wingsummer@wingsummer-PC ~ → echo ${animals[*]}
cat dog butterfly fish ant goose cow chick goat pig

  要僅獲取陣列的一部分,必須指定要從中開始的索引,後跟要從陣列中檢索的元素數,以冒號分隔:

wingsummer@wingsummer-PC ~ → echo ${animals[*]:5:3}
goose cow chick

  上面的查詢本質上是這樣的:從陣列的第六個元素開始獲取3個陣列元素(記住第六個元素的索引為5)。
  可以使用#來獲取陣列的長度:

wingsummer@wingsummer-PC ~ → echo ${#animals[*]}
10

  可以使用加號等於運算子+=將陣列新增到陣列的末尾:

animals=(cat dog fish)
echo ${animals[*]}
animals+=(cow chick goat)
echo ${animals[*]}

內容總結

  • 陣列是一種線性資料結構,具有可儲存在變數中的有序元素。
  • 陣列的每個元素都有一個索引,第一個索引是0。
  • 可以使用陣列的索引來訪問陣列的各個元素。

小試牛刀

  1. 編寫一個 bash 指令碼,在指令碼中定義一個陣列,指令碼的第一個引數指示執行指令碼時列印到控制檯的陣列元素的索引。
  2. 編寫一個 bash 指令碼,在指令碼中定義兩個陣列,當指令碼執行時,陣列長度的總和將列印到控制檯。
? 點選檢視答案 ?
#1
index=$1
animals=(cat dog butterfly fish bird goose cow chick goat pig)
echo "${animals[$index]}"

#2
animals1=(cat dog butterfly fish bird)
animals2=(goose cow chick goat pig)
echo $((${#animals1[*]}+${#animals2[*]}))

花括號擴充套件

內容介紹

  Bash 有一個非常方便的工具,用於從序列中建立字串,稱為大括號擴充套件。大括號展開使用花括號和兩個句點{..}建立一系列字母或數字。例如,要建立一個所有數字都在0到9之間的字串,可以執行以下操作:

echo {0..9}

  除了數字,您還可以建立字母序列:

echo {a..e}
echo {W..Z}

  您可以將字串放在花括號的任一側,它們將“貼上”到序列的相應端:

echo a{0..4}
echo b{0..4}c

  還可以組合序列,以便將兩個或多個序列連線在一起:

echo {1..3}{A..C}

  如果要使用變數來定義序列,則需要使用eval命令來建立序列:

wingsummer@wingsummer-PC ~ → start=4
wingsummer@wingsummer-PC ~ → end=9
wingsummer@wingsummer-PC ~ → echo {$start..$end}
{4..9}
wingsummer@wingsummer-PC ~ → eval echo {$start..$end}
4 5 6 7 8 9

  可以在括號{,}之間用逗號組合序列:

wingsummer@wingsummer-PC ~ → echo {{1..3},{a..c}}
1 2 3 a b c

  你還可以使用任意數量的字串來執行此操作:

wingsummer@wingsummer-PC ~ → echo {Who,What,Why,When,How}?
Who? What? Why? When? How?

內容總結

  • 大括號允許建立字串序列和展開。
  • 要使用帶大括號的變數,需要使用eval命令。

小試牛刀

  1. 使用大括號展開建立100個文字檔案。
? 點選檢視答案 ?
wingsummer@wingsummer-PC txts → eval touch {0..100}.txt
wingsummer@wingsummer-PC txts → ls
0.txt    16.txt  23.txt  30.txt  38.txt  45.txt  52.txt  5.txt   67.txt  74.txt  81.txt  89.txt  96.txt
100.txt  17.txt  24.txt  31.txt  39.txt  46.txt  53.txt  60.txt  68.txt  75.txt  82.txt  8.txt   97.txt
10.txt   18.txt  25.txt  32.txt  3.txt   47.txt  54.txt  61.txt  69.txt  76.txt  83.txt  90.txt  98.txt
11.txt   19.txt  26.txt  33.txt  40.txt  48.txt  55.txt  62.txt  6.txt   77.txt  84.txt  91.txt  99.txt
12.txt   1.txt   27.txt  34.txt  41.txt  49.txt  56.txt  63.txt  70.txt  78.txt  85.txt  92.txt  9.txt
13.txt   20.txt  28.txt  35.txt  42.txt  4.txt   57.txt  64.txt  71.txt  79.txt  86.txt  93.txt
14.txt   21.txt  29.txt  36.txt  43.txt  50.txt  58.txt  65.txt  72.txt  7.txt   87.txt  94.txt
15.txt   22.txt  2.txt   37.txt  44.txt  51.txt  59.txt  66.txt  73.txt  80.txt  88.txt  95.txt

迴圈

  迴圈是 Bash 語言中最重要的程式設計結構之一。到目前為止,我們編寫的所有程式都是從指令碼的第一行執行到最後一行,但迴圈允許您根據邏輯條件或按照順序重複程式碼行。我們要討論的第一種迴圈是FOR迴圈。FOR迴圈遍歷指定序列的每個元素。讓我們看一下迴圈的一個小例子:

#!/usr/bin/env bash
# File: forloop.sh

echo "Before Loop"

for i in {1..3}
do
    echo "i is equal to $i"
done

echo "After Loop"

  現在讓我們執行指令碼,結果如下:

Before Loop
i is equal to 1
i is equal to 2
i is equal to 3
After Loop

  讓我們逐行分析forloop.sh。首先,在FOR迴圈之前列印Before Loop,然後迴圈開始。FOR迴圈以for [variable name] in [sequence]的語法開頭,然後是下一行的do。在for之後立即定義的變數名將在迴圈內部接受一個值,該值對應於在in之後提供的序列中的一個元素,從序列的第一個元素開始,然後是每個後續元素。有效序列包括大括號展開、字串的顯式列表、陣列和命令替換。在這個例子中,我們使用了大括號擴充套件{1..3},我們知道它會擴充套件到字串1 2 3。迴圈的每次迭代中執行的程式碼都是在dodone之間編寫的。在迴圈的第一次迭代中,變數$i包含值1。字串i is equal to 1被列印到控制檯。在1之後的大括號擴充套件中有更多元素,因此在第一次到達完成位置後,程式開始在do語句處執行。第二次迴圈變數$i包含值2。字串i is equal to 2被列印到控制檯,然後迴圈返回do語句,因為序列中仍有元素。$i變數現在等於3,因此i is equal to 3會列印到控制檯。序列中沒有剩餘的元素,因此程式將超出FOR迴圈,並最終列印After Loop
  一旦你做了一些實驗,看看這個例子,看看其他幾種序列生成策略:

#!/usr/bin/env bash
# File: manyloops.sh

echo "Explicit list:"

for picture in img001.jpg img002.jpg img451.jpg
do
    echo "picture is equal to $picture"
done

echo ""
echo "Array:"

stooges=(curly larry moe)

for stooge in ${stooges[*]}
do
    echo "Current stooge: $stooge"
done

echo ""
echo "Command substitution:"

for code in $(ls)
do
    echo "$code is a bash script"
done

  然後執行程式碼:

Explicit list:
picture is equal to img001.jpg
picture is equal to img002.jpg
picture is equal to img451.jpg

Array:
Current stooge: curly
Current stooge: larry
Current stooge: moe

Command substitution:
bigmath.sh is a bash script
condexif.sh is a bash script
forloop.sh is a bash script
letsread.sh is a bash script
manyloops.sh is a bash script
math.sh is a bash script
nested.sh is a bash script
simpleelif.sh is a bash script
simpleif.sh is a bash script
simpleifelse.sh is a bash script
vars.sh is a bash script

  上面的示例演示了為for迴圈建立序列的其他三種方法:鍵入顯式列表、使用陣列和獲取命令替換的結果。在每種情況下,for後面都會宣告一個變數名,而變數的值在迴圈的每次迭代中都會發生變化,直到相應的序列用完為止。現在你應該花點時間自己寫幾個FOR迴圈,用我們已經討論過的所有方法生成序列,只是為了加強你對FOR迴圈如何工作的理解。迴圈和條件語句是程式設計師可以使用的兩種最重要的結構。
  現在我們已經有了一些FOR迴圈基礎,讓我們繼續討論WHILE迴圈。讓我們看一個WHILE迴圈的例子:

#!/usr/bin/env bash
# File: whileloop.sh

count=3

while [[ $count -gt 0 ]]
do
  echo "count is equal to $count"
  let count=$count-1
done

  WHILE迴圈首先以while關鍵字開頭,然後是一個條件表示式。只要迴圈迭代開始時條件表示式等價於true,那麼WHILE迴圈中的程式碼將繼續執行。當我們執行這個指令碼時,你認為控制檯會列印什麼?讓我們看看結果:

count is equal to 3
count is equal to 2
count is equal to 1

  在WHILE之前,count變數設定為3,但每次執行WHILE迴圈時,count的值都會減去1。然後迴圈再次從頂部開始,並重新檢查條件表示式,看它是否仍然等效於true。經過三次迭代後,迴圈計數等於0,因為每次迭代的計數都會減去1。因此,邏輯表示式[[ $count -gt 0]]不再等於true,迴圈結束。通過改變迴圈內部邏輯表示式中變數的值,我們可以確保邏輯表示式最終等價於false,因此迴圈最終將結束。
  如果邏輯表示式永遠不等於false,那麼我們就建立了一個無限迴圈,因此迴圈永遠不會結束,程式永遠執行。顯然,我們希望我們的程式最終結束,因此建立無限迴圈是不可取的。然而,讓我們建立一個無限迴圈,這樣我們就知道如果我們的程式無法終止該怎麼辦。通過一個簡單的“錯誤輸入”,我們可以改變上面的程式,使其永遠執行,但用加號+替換減號,這樣每次迭代後計數總是大於零(並不斷增長):

#!/usr/bin/env bash
# File: foreverloop.sh

count=3

while [[ $count -gt 0 ]]
do
  echo "count is equal to $count"
  let count=$count+1              # We only changed this line!
done

  如下是部分執行結果:


...
count is equal to 29026
count is equal to 29027
count is equal to 29028
count is equal to 29029
count is equal to 29030
...

  如果程式正在執行,那麼計數會快速增加,你會看到數字在你的終端中飛馳而過!不要擔心,你可以使用Control+C終止任何陷入無限迴圈的程式。使用Control+C返回終端,這樣我們就可以繼續其他操作。
  在構造WHILE迴圈時,一定要確保你已經構建了程式,這樣迴圈才會終止!如果while之後的邏輯表示式從未變為false,那麼程式將永遠執行,這可能不是您為程式計劃的行為。
  就像forwhile迴圈的IF語句可以相互巢狀一樣。在下面的示例中,一個FOR迴圈巢狀在另一個FOR迴圈中:

#!/usr/bin/env bash
# File: nestedloops.sh

for number in {1..3}
do
  for letter in a b
  do
    echo "number is $number, letter is $letter"
  done
done

  根據我們對FOR迴圈的瞭解,嘗試在執行程式之前預測該程式將列印出什麼。現在你已經寫下或列印出你的預測,讓我們執行它:

number is 1, letter is a
number is 1, letter is b
number is 2, letter is a
number is 2, letter is b
number is 3, letter is a
number is 3, letter is b

  讓我們仔細看看這裡發生了什麼。最外層的FOR迴圈開始遍歷{1..3}生成的序列。在第一次通過迴圈時,內迴圈通過序列a b進行迭代,首先列印數字為1,字母為a,然後數字為1,字母為b。然後完成外迴圈的第一次迭代,整個過程以數字為2的值重新開始。這個過程將繼續通過內迴圈,直到外迴圈的順序耗盡。我再次強烈建議您暫停片刻,根據上面的程式碼編寫一些自己的巢狀迴圈。在執行程式之前,嘗試預測巢狀迴圈程式將列印什麼。如果列印的結果與您的預測不符,請在程式中查詢原因。不要只侷限於巢狀FOR迴圈,使用巢狀WHILE迴圈,或巢狀組合中的FORWHILE迴圈。
  除了在彼此之間巢狀迴圈之外,還可以在IF語句中巢狀迴圈,在迴圈中巢狀IF語句。讓我們看一個例子:

#!/usr/bin/env bash
# File: ifloop.sh

for number in {1..10}
do
  if [[ $number -lt 3 ]] || [[ $number -gt 8 ]]
  then
    echo $number
  fi
done

  在我們執行這個示例之前,請再次嘗試猜測輸出將是什麼:

1
2
9
10

  對於上面迴圈的每次迭代,都會在IF語句中檢查number的值,只有當number超出38的範圍時,才會執行echo命令。
  巢狀IF語句和迴圈有無數種組合,但有一個好的經驗法則是,巢狀深度不應超過兩層或三層。如果你發現自己寫的程式碼有很多巢狀,你應該考慮重組你的程式。深度巢狀的程式碼很難閱讀,如果您的程式包含錯誤,則更難除錯。

內容總結

  • 迴圈允許你重複程式的各個部分。
  • FOR迴圈在一個序列中迭代,這樣,在迴圈的每次迭代中,指定的變數都會取序列中每個元素的值,而WHILE迴圈則在每次迭代開始時檢查條件語句。
  • 如果條件等價於true,則執行迴圈的一次迭代,然後再次檢查條件語句。否則迴圈就結束了。
  • IF語句和迴圈可以巢狀,以形成更強大的程式設計結構。

小試牛刀

  1. 編寫幾個具有三級巢狀的程式,包括FOR迴圈、WHILE迴圈和IF語句。在執行程式之前,請嘗試預測程式將要列印的內容。如果結果與你的預測不同,試著找出原因。
  2. 在控制檯中輸入yes命令,然後停止程式執行。檢視yes的手冊頁,瞭解更多有關該程式的資訊。
? 點選檢視答案 ?
# 略

擴充

  上面的迴圈是用的比較常見的幾種,還有until迴圈和類似C語言的for迴圈。我們既要有寫迴圈的能力,我們還要有操縱迴圈的能力,本部分擴充套件將會介紹。
  until迴圈與while迴圈恰好相反,只要不符合判斷條件(判斷條件失敗),就不斷迴圈執行指定的語句。一旦符合判斷條件,就退出迴圈。

until condition; do
  commands
done

  關鍵字do可以與until不寫在同一行,這時兩者之間不需要分號分隔。

until condition
do
  commands
done

  下面是一個例子:

$ until false; do echo 'Hi, until looping ...'; done
Hi, until looping ...
Hi, until looping ...
Hi, until looping ...
^C

  上面程式碼中,until的部分一直為false,導致命令無限執行,必須按下Ctrl + C終止。

#!/bin/bash

number=0
until [ "$number" -ge 10 ]; do
  echo "Number = $number"
  number=$((number + 1))
done

  上面例子中,只要變數number小於10,就會不斷加1,直到number大於等於10,就退出迴圈。
  until的條件部分也可以是一個命令,表示在這個命令執行成功之前,不斷重複嘗試。

until cp $1 $2; do
  echo 'Attempt to copy failed. waiting...'
  sleep 5
done

上面例子表示,只要cp $1 $2這個命令執行不成功,就5秒鐘後再嘗試一次,直到成功為止。

until迴圈都可以轉為while迴圈,只要把條件設為否定即可。上面這個例子可以改寫如下。

while ! cp $1 $2; do
  echo 'Attempt to copy failed. waiting...'
  sleep 5
done

  一般來說,until用得比較少,完全可以統一都使用while
  for迴圈還支援C語言的迴圈語法。

for (( expression1; expression2; expression3 )); do
  commands
done

  上面程式碼中,expression1用來初始化迴圈條件,expression2用來決定迴圈結束的條件,expression3在每次迴圈迭代的末尾執行,用於更新值。注意,迴圈條件放在雙重圓括號之中。另外,圓括號之中使用變數,不必加上美元符號$。它等同於下面的while迴圈。

(( expression1 ))
while (( expression2 )); do
  commands
  (( expression3 ))
done

  下面是一個例子:

for (( i=0; i<5; i=i+1 )); do
  echo $i
done

  上面程式碼中,初始化變數i的值為0,迴圈執行的條件是i小於5。每次迴圈迭代結束時,i的值加1
  for條件部分的三個語句,都可以省略。

for ((;;))
do
  read var
  if [ "$var" = "." ]; then
    break
  fi
done

  上面指令碼會反覆讀取命令列輸入,直到使用者輸入了一個點.為止,才會跳出迴圈。
  Bash 提供了兩個內部命令breakcontinue,用來在迴圈內部跳出迴圈。break命令立即終止迴圈,程式繼續執行迴圈塊之後的語句,即不再執行剩下的迴圈。

#!/bin/bash

for number in 1 2 3 4 5 6
do
  echo "number is $number"
  if [ "$number" = "3" ]; then
    break
  fi
done

  上面例子只會列印3行結果。一旦變數$number等於3,就會跳出迴圈,不再繼續執行。
  continue命令立即終止本輪迴圈,開始執行下一輪迴圈。

#!/bin/bash

while read -p "What file do you want to test?" filename
do
  if [ ! -e "$filename" ]; then
    echo "The file does not exist."
    continue
  fi

  echo "You entered a valid file.."
done

  上面例子中,只要使用者輸入的檔案不存在,continue命令就會生效,直接進入下一輪迴圈(讓使用者重新輸入檔名),不再執行後面的列印語句。
  Bash 還提供了一個比較獨特的指令:select。該結構主要用來生成簡單的選單。它的語法與for...in迴圈基本一致。

select name
[in list]
do
  commands
done

  Bash 會對select依次進行下面的處理。

  1. select生成一個選單,內容是列表list的每一項,並且每一項前面還有一個數字編號。
  2. Bash 提示使用者選擇一項,輸入它的編號。
  3. 使用者輸入以後,Bash 會將該項的內容存在變數name,該項的編號存入環境變數REPLY。如果使用者沒有輸入,就按Enter鍵,Bash 會重新輸出選單,讓使用者選擇。
  4. 執行命令體commands
  5. 執行結束後,回到第一步,重複這個過程。

  下面是一個例子:

#!/bin/bash
# select.sh

select brand in Samsung Sony iphone symphony Walton
do
  echo "You have chosen $brand"
done

  執行上面的指令碼,Bash 會輸出一個品牌的列表,讓使用者選擇:

wingsummer@wingsummer-PC ~ → ./select.sh
1) Samsung
2) Sony
3) iphone
4) symphony
5) Walton
#?

  如果使用者沒有輸入編號,直接按Enter鍵。Bash 就會重新輸出一遍這個選單,直到使用者按下Ctrl + C,退出執行。select可以與case結合,針對不同項,執行不同的命令。

#!/bin/bash

echo "Which Operating System do you like?"

select os in Ubuntu LinuxMint Windows8 Windows10 WindowsXP
do
  case $os in
    "Ubuntu"|"LinuxMint")
      echo "I also use $os."
    ;;
    "Windows8" | "Windows10" | "WindowsXP")
      echo "Why don't you try Linux?"
    ;;
    *)
      echo "Invalid entry."
      break
    ;;
  esac
done

  上面例子中,case針對使用者選擇的不同項,執行不同的命令。

函式

  函式是可以重複使用的程式碼片段,有利於程式碼的複用。函式總是在當前 Shell 執行,這是跟指令碼的一個重大區別,Bash 會新建一個子 Shell 執行指令碼。如果函式與指令碼同名,函式會優先執行。但是,函式的優先順序不如別名,即如果函式與別名同名,那麼別名優先執行。

  Bash 函式定義的語法有兩種:

# 第一種
fn() {
  # codes
}

# 第二種
function fn() {
  # codes
}

  上面程式碼中,fn是自定義的函式名,函式程式碼就寫在大括號之中。這兩種寫法是等價的。下面是一個簡單函式的例子:

hello() {
  echo "Hello $1"
}

  上面程式碼中,函式體裡面的$1表示函式呼叫時的第一個引數。
  呼叫函式時,就直接寫函式名,引數跟在函式名後面。

wingsummer@wingsummer-PC ~ → hello world
Hello world

  下面是一個多行函式的例子,顯示當前日期時間。

today() {
  echo -n "Today's date is: "
  date +"%A, %B %-d, %Y"
}

  刪除一個函式,可以使用unset命令。
  函式體內可以使用引數變數,獲取函式引數。函式的引數變數,與指令碼引數變數是一致的。

  • $1~$9 :函式的第一個到第9個的引數。
  • $0 :函式所在的指令碼名。
  • $# :函式的引數總數。
  • $@ :函式的全部引數,引數之間使用空格分隔。
  • $* :函式的全部引數,引數之間使用變數$IFS值的第一個字元分隔,預設為空格,但是可以自定義。

  如果函式的引數多於9個,那麼第10個引數可以用${10}的形式引用,以此類推。下面是一個示例指令碼test.sh

#!/bin/bash
# test.sh

function alice {
  echo "alice: $@"
  echo "$0: $1 $2 $3 $4"
  echo "$# arguments"

}

alice in wonderland

  執行該指令碼,結果如下:

alice: in wonderland
test.sh: in wonderland
2 arguments

  上面例子中,由於函式alice只有第一個和第二個引數,所以第三個和第四個引數為空。下面是一個日誌函式的例子:

function log_msg {
  echo "[`date '+ %F %T'` ]: $@"
}

使用方法如下:

wingsummer@wingsummer-PC ~ → log_msg "This is sample log message"
[ 2020-05-13 17:52:34 ]: This is sample log message

  return命令用於從函式返回一個值。函式執行到這條命令,就不再往下執行了,直接返回了。

function func_return_value {
  return 10
}

  函式將返回值返回給呼叫者。如果命令列直接執行函式,下一個命令可以用$?拿到返回值。

wingsummer@wingsummer-PC ~ → func_return_value
wingsummer@wingsummer-PC ~ → echo "Value returned by function is: $?"
Value returned by function is: 10

  return後面不跟引數,只用於返回也是可以的。

function name {
  commands
  return
}

  Bash 函式體內直接宣告的變數,屬於全域性變數,整個指令碼都可以讀取。這一點需要特別小心。

# 指令碼 test.sh
fn () {
  foo=1
  echo "fn: foo = $foo"
}

fn
echo "global: foo = $foo"

  上面指令碼的執行結果如下:

wingsummer@wingsummer-PC ~ → bash test.sh
fn: foo = 1
global: foo = 1

  上面例子中,變數$foo是在函式fn內部宣告的,函式體外也可以讀取。函式體內不僅可以宣告全域性變數,還可以修改全域性變數。

#! /bin/bash
foo=1

fn () {
  foo=2
}

fn

echo $foo

  上面程式碼執行後,輸出的變數$foo值為2。
  函式裡面可以用local命令宣告區域性變數:

#! /bin/bash
# 指令碼 test.sh
fn () {
  local foo
  foo=1
  echo "fn: foo = $foo"
}

fn
echo "global: foo = $foo"

  上面指令碼的執行結果如下:

wingsummer@wingsummer-PC ~ → bash test.sh
fn: foo = 1
global: foo =

  上面例子中,local命令宣告的$foo變數,只在函式體內有效,函式體外沒有定義。

內容總結

  • 函式以function關鍵字開頭,後跟函式名和花括號。
  • 函式是小的、可重用的程式碼片段,其行為與命令類似。可以使用$1$2$@等變數為函式提供引數,就像Bash指令碼一樣。
  • 使用local關鍵字可防止函式建立或修改全域性變數。

Bash 陷阱

  我們在編寫 Bash 指令碼的時候總會犯一些錯誤。如下是常見的例子,每一個例子在某些方面都有缺陷。如果想看比較完整的,如果有英文能力,可以到 BashPitfalls 進行閱讀。

for f in $(ls *.mp3)

  BASH 程式設計師最常見的錯誤之一是編寫如下迴圈:

for f in $(ls *.mp3); do    # 錯誤!
    echo $f                 # 錯誤!
done

for f in $(ls)              # 錯誤!
for f in `ls`               # 錯誤!

for f in $(find . -type f)  # 錯誤!
for f in `find . -type f`   # 錯誤!

files=($(find . -type f))   # 錯誤!
for f in ${files[@]}        # 錯誤!

  是的,如果您可以將lsfind的輸出視為一個檔名列表並對其進行迭代,那就太好了,但你不能。整個方法都有致命的缺陷,沒有任何技巧可以讓它發揮作用。你必須使用完全不同的方法。
  這至少有6個問題:

  1. 如果檔名包含空格(或當前值$IFS中的任何字元),它將進行分詞。假設我們有一個名為01 - Don't Eat the Yellow Snow.mp3的檔案。在當前目錄中,for迴圈將迭代生成的檔名中的每個單詞:01-Don'tEat等等。
  2. 如果檔名包含glob字元,它將進行檔名擴充套件。如果ls生成任何包含*字元的輸出,則包含該字元的單詞將被識別為一個模式,並替換為與之匹配的所有檔名的列表。
  3. 如果命令替換返回多個檔名,則無法判斷第一個檔名從何處結束,第二個檔名從何處開始。路徑名可以包含除NUL以外的任何字元。是的,這包括新行。
  4. ls實用程式可能會損壞檔名。根據您所在的平臺、使用(或未使用)的引數,以及其標準輸出是否指向終端,ls可能會隨機決定將檔名中的某些字元替換為?,或者乾脆不列印。永遠不要試圖解析ls的輸出。ls完全沒有必要。它是一個外部命令,其輸出專門由人讀取,而不是由指令碼解析。
  5. 命令替代會從輸出中刪除所有尾隨的換行符。這似乎是可取的,因為ls新增了一個換行符,但如果列表中的最後一個檔名以換行符結尾,$()也將刪除該檔名。
  6. ls示例中,如果第一個檔名以連字元開頭,可能會導致3號陷阱。

  你也不能簡單地重複引用替換詞:

for f in "$(ls *.mp3)"; do     # 錯誤!

  這會導致ls的整個輸出被視為一個詞。迴圈將只執行一次,而不是遍歷每個檔名,將所有檔名拼湊在一起的字串分配給f。你也不能簡單地把IFS改成新行,檔名也可以包含換行符。
  另一個變體是濫用分詞和for迴圈(錯誤地)讀取檔案的行。例如:

IFS=$'\n'
for line in $(cat file); do …     # 錯誤!

  這不管用,尤其是如果這些行是檔名。Bash就是不能這樣工作。那麼,正確的方法是什麼?
  有幾種方法,主要取決於是否需要遞迴擴充套件。如果不需要遞迴,可以使用簡單的檔名擴充套件。代替ls

for file in ./*.mp3; do    # 更好! 並且…
    some command "$file"   # …一定要給擴充套件變數加雙引號
done

  POSIX shell(如Bash)具有專門用於此目的的檔名擴充套件功能,允許 shell 將模式擴充套件為匹配檔名的列表。不需要解釋外部效用的結果。因為檔名擴充套件是最後一個擴充套件步驟,所以每個匹配的./*.mp3正確地擴充套件,並且不受無引號擴充套件的影響。但問題是:如果當前目錄中沒有mp3檔案會怎麼樣呢?然後使用file="./*.mp3"執行一次for迴圈,這不是預期的行為!解決方法是測試是否存在匹配的檔案:

# POSIX
for file in ./*.mp3; do
    [ -e "$file" ] || continue
    some command "$file"
done

  另一個解決方案是使用 Bash 的shopt -s nullglob特性,不過這隻能在閱讀文件並仔細考慮此設定對指令碼中所有其他檔名擴充套件的影響後才能完成。如果需要遞迴,標準解決方案是find。使用find時,請確保正確使用它。要實現POSIX sh的可移植性,請使用-exec選項:

find . -type f -name '*.mp3' -exec some command {} \;

# 或者如果命令要獲取多個檔案輸入:

find . -type f -name '*.mp3' -exec some command {} +

  如果您使用的是 bash ,那麼您還有兩個額外的選項。一種是使用 GNU 或 BSD find-print0選項,以及 bash 的read -d選項和過程替代(ProcessSubstitution):

while IFS= read -r -d '' file; do
  some command "$file"
done < <(find . -type f -name '*.mp3' -print0)

  這裡的優點是some command(實際上是整個while迴圈體)在當前shell中執行。您可以設定變數,並在迴圈結束後讓它們保持不變。
  Bash 4.0及更高版本中提供的另一個選項是globstar,它允許遞迴地擴充套件glob

shopt -s globstar
for file in ./**/*.mp3; do
  some command "$file"
done

以破折號開頭的檔名

  帶前導破折號的檔名可能會導致許多問題,像*.mp3被分類到一個擴充套件列表中(根據您當前的語言環境),並且在大多數語言環境中,在字母之前排序。然後將列表傳遞給某個命令,該命令可能會錯誤地將-filename解釋為一個選項。這有兩個主要的解決方案。一種解決方案是在命令(如cp)及其引數之間插入。這告訴它停止掃描選項,一切都很好:

cp -- "$file" "$target"

  這種方法存在潛在的問題。您必須確保插入--在可能被解釋為選項的上下文中,每次使用引數時都要插入--這很容易遺漏,並且可能涉及大量冗餘。大多數編寫良好的選項解析庫都理解這一點,正確使用它們的程式應該自由繼承該功能。然而,仍然要知道,最終由應用程式來識別結束選項。一些手動解析選項、錯誤解析選項或使用糟糕的第三方庫的程式可能無法識別。除了 POSIX 指定的一些例外情況,比如echo
  另一個解決方案是通過使用相對或絕對路徑名來確保檔名始終以目錄開頭:

for i in ./*.mp3; do
    cp "$i" /target
    …
done

  在這種情況下,即使我們有一個名稱以-開頭的檔案,檔名擴充套件也將確保變數擴充套件為類似./-foo.mp3想形式。就cp而言,這是完全安全的。
  最後,如果可以保證所有結果都具有相同的字首,並且在迴圈體中只使用變數幾次,則可以簡單地將字首與擴充套件連線起來。這在理論上節省了為每個詞生成和儲存幾個額外字元的時間。

for i in *.mp3; do
    cp "./$i" /target
    …
done

[ $foo = "bar" ]

  這種寫法是有比較大的問題的,此示例可能因以下幾個原因而中斷出錯:

  • 如果foo變數不存在或者是空的,最後結果就是這樣的:

    [ = "bar" ] # 錯誤!
    

    這會丟擲unary operator expected異常。

  • 如果foo變數中含有空格,結果會和下面的比較類似:

    [ multiple words here = "bar" ]
    

    這會導致語法錯誤,正確的寫法應該是這樣的:

    # POSIX
    [ "$foo" = bar ] # 正確!
    

    即使$foo-開頭,這在符合POSIX的實現上也可以很好地工作,因為POSIX[根據傳遞給它的引數的數量來確定其操作。只有非常古老的shell才有這個問題,在編寫新程式碼時,您不必擔心它們。

  在 Bash 和許多其他類似 ksh 的 shell 中,有一個更好的選擇,它使用[[]]關鍵字。

# Bash / Ksh
[[ $foo == bar ]] # 正確!

  您不需要在[[]]中的=左側引用變數加雙引號,因為它們不會進行分詞或全域性搜尋,即使是空白變數也會得到正確處理。另一方面,引用它們也不會有什麼壞處。與[test不同,你也可以使用功能相同的==。但是請注意,使用[]進行的比較會對右側的字串執行模式匹配,而不僅僅是簡單的字串比較。要使字串位於正確的文字上,如果使用了在模式匹配上下文中具有特殊意義的任何字元,則必須給它加上雙引號。

# Bash / Ksh
match=b*r
[[ $foo == "$match" ]] # 不錯! 未加雙引號也將與 b*r 匹配.

  你可能見過這樣的程式碼:

# POSIX / Bourne
[ x"$foo" = xbar ] # 可以,但通常沒必要.

  必須在非常古老的 shell 上執行的程式碼需要x"$foo"技巧,這些 shell 缺少[[並且有一個更原始的[,如果$foo-!(開頭,則會產生混淆,在上述較舊的系統上,[只需要對=左側的標記格外小心,這個技巧它能正確處理右側的標記。

[ "$foo" = bar && "$bar" = foo ]

  不能在舊的test[]命令中使用&&命令。Bash 解析器會看到[[]](())之外的&&命令,並將命令分為兩個命令,在&&命令之前和之後。請使用以下選項之一:

[ bar = "$foo" ] && [ foo = "$bar" ] # 正確! (POSIX)
[[ $foo = bar && $bar = foo ]]       # 正確! (Bash / Ksh)

[[ $foo > 7 ]]

  這裡有很多問題。第一[[]]命令不應僅用於計算算術表示式。它應用於涉及受支援的test運算子之一的測試表示式。雖然從技術上講,您可以使用[[]]的一些運算子進行數學運算,但只有與表示式中某個位置的非數學測試運算子結合使用才有意義。如果您只想進行數值比較(或任何其他shell演算法),只使用(())要好得多:

# Bash / Ksh
((foo > 7))     # 正確!
[[ foo -gt 7 ]] # 能用,但不常用,建議用 ((…))

  如果在[[]]內使用>運算子,則會將其視為字串比較(按區域設定測試排序順序),而不是整數比較。這有時可能有效,但在你最意想不到的時候就會失敗。如果在[]內使用>則更糟糕,這是一個輸出重定向。您將在目錄中獲得一個名為7的檔案,只要$foo不為空,test就會成功。
  如果嚴格的 POSIX 一致性是一項要求,並且(())不可用,則使用[]的正確替代方案是:

# POSIX
[ "$foo" -gt 7 ]       # 正確!
[ "$((foo > 7))" -ne 0 ] # 相容 POSIX ,和 (()) 一樣的功能,可以做更復雜的數學運算

  如果$foo的內容沒有經過驗證,並且超出了你的控制(例如,如果它們來自外部源),那麼除了["$foo" -gt 7]之外的所有內容都構成了命令注入漏洞,因為$foo的內容被解釋為算術表示式。例如,a[$(reboot)]的算術表示式在計算時會執行reboot命令。[]裡面要求運算元為十進位制整數,因此不受影響。但引用$foo非常關鍵,否則仍然會出現漏洞。
  如果無法保證任何算術上下文,包括變數定義、變數引用、數值比較的測試表示式的輸入,則必須始終在計算表示式之前驗證輸入。

# POSIX
case $foo in
    ("" | *[!0123456789]*)
        printf '$foo is not a sequence of decimal digits: "%s"\n' "$foo" >&2
        exit 1
        ;;
    *)
        [ "$foo" -gt 7 ]
esac

if [bar="$foo"]; then …

[bar="$foo"]     # 錯!
[ bar="$foo" ]   # 還錯!
[bar = "$foo"]   # 也錯了!
[[bar="$foo"]]   # 又錯了!
[[ bar="$foo" ]] # 猜一猜?還是錯了!
[[bar = "$foo"]] # 我還有必要說這個嗎?

  正如前一個例子中所解釋的,[是一個命令,可以用type -t [whence -v [來證明。就像其他任何簡單的命令一樣,Bash 希望該命令後面有一個空格,然後是第一個引數,然後是另一個空格等等。如果不把空格放進去,就不能把所有的東西都放在一起執行!以下是正確的方法:

if [ bar = "$foo" ]; then …

if [[ bar = "$foo" ]]; then …

read $foo

  在read命令中,變數名前不能使用$。如果要將資料放入名為foo的變數中,可以這樣做:

read foo

  如果想更安全的寫法:

IFS= read -r foo

  這將讀取一行輸入,並將其放入名為$foo的變數中。如果你真的想把foo作為對其他變數的引用,這可能會很有用;但在大多數情況下,這只是一個bug

cat file | sed s/foo/bar/ > file

  不能在同一管道中讀取和寫入檔案。根據管道所做的工作,檔案可能會被刪除,或者它可能會增長,直到填滿可用磁碟空間,或者達到作業系統的檔案大小限制或配額,等等。
  如果希望安全地對檔案進行更改,而不是附加到檔案末尾,請使用文字編輯器。

printf %s\\n ',s/foo/bar/g' w q | ed -s file

  如果您正在做的事情無法用文字編輯器完成,則必須在某個點建立一個臨時檔案。例如,以下是完全可移植的:

sed 's/foo/bar/g' file > tmpfile && mv tmpfile file

  以下內容僅適用於GNU sed 4.x:

sed -i 's/foo/bar/g' file(s)

echo $foo

  這個看起來相對人畜無害的命令引起了巨大的混亂。因為$foo沒有被引用,它不僅會被分詞,還會被檔案替換。這會誤導 Bash 程式設計師,讓他們認為自己的變數包含錯誤的值,而事實上變數是可以的,只是單詞拆分或檔名擴充套件擾亂了他們對所發生事情的看法。

msg="Please enter a file name of the form *.zip"
echo $msg

  此訊息被拆分為多個單詞,任何檔名擴充套件都會展開:

Please enter a file name of the form freenfss.zip lw35nfss.zip

echo <<EOF

  <<是在指令碼中嵌入大量文字資料的有用工具。它會將指令碼中的文字行重定向到命令的標準輸入。不幸的是,echo不是從stdin讀取的命令。

# 如下是錯誤的示例:
wingsummer@wingsummer-PC ~ → echo <<EOF
Hello world
How's it going?
EOF

# 你試圖這麼做:
wingsummer@wingsummer-PC ~ → cat <<EOF
Hello world
How's it going?
EOF

# 或者使用內建命令 echo :
wingsummer@wingsummer-PC ~ → echo "Hello world
How's it going?"

  使用這樣的引號很好,它在所有 shell 中都非常有效,但它不允許您只在指令碼中插入一行程式碼。第一行和最後一行都有語法標記。如果你想讓您的行不受 shell 語法的影響,並且不想生成cat命令,那麼還有另一種選擇:

 # 或者使用內建命令 printf :
  printf %s "\
  Hello world
  How's it going?
  "

cd /foo; bar

  如果不檢查cd命令中的錯誤,可能會在錯誤的位置執行bar。例如,如果bar恰好是rm -f *,這可能是一場重大災難。故必須始終檢查cd命令中的錯誤,最簡單的方法是:

cd /foo && bar

cmd1 && cmd2 || cmd3

  有些人試圖使用&&||作為if…then…else…fi的快捷語法,可能是因為他們認為自己很聰明。例如:

# 錯誤!
[[ -s $errorlog ]] && echo "Uh oh, there were some errors." || echo "Successful."

  然而,在一般情況下,這種構造並不完全等同於if…fi&&之後的命令也會生成退出狀態,如果退出狀態不是true,那麼||之後的命令也會被呼叫。例如:

 i=0
 true && ((i++)) || ((i--))  # 錯!
 echo "$i"                   # 列印 0

echo "Hello World!"

  這裡的問題是,在互動式 Bash shell(在4.3之前的版本中)中,您會看到如下錯誤:

bash: !": event not found

  這是因為,在互動式 shel l的預設設定中,Bash 使用感嘆號執行csh風格的歷史擴充套件。這在 shell 指令碼中不是問題;只有在互動式shell中。不幸的是,顯然試圖“修復”這一問題是行不通的:

wingsummer@wingsummer-PC ~ → echo "hi\!"
hi\!

  最簡單的解決方案是取消histexpand選項:這可以通過set +Hset +o histexpand完成。

for arg in $*

  Bash(和所有 Bourne Shell 一樣)有一種特殊的語法,可以一次引用一個位置引數列表,而$*不是嗎,$@也不是。這兩個引數都會擴充套件到指令碼引數中的單詞列表,而不是作為單獨的單詞擴充套件到每個引數。正確的語法是:

for arg in "$@"

# 或者就:
for arg

  由於在指令碼中迴圈位置引數是很常見的事情,所以for arg$@中預設為for arg"$@"是一種特殊的語法,它可以將每個引數用作單個單詞(或單個迴圈迭代),這是你至少99%的時間應該使用的東西。
  如下是一個例子:

# 不正確的版本
for x in $*; do
  echo "parameter: '$x'"
done

wingsummer@wingsummer-PC ~ → ./myscript 'arg 1' arg2 arg3
parameter: 'arg'
parameter: '1'
parameter: 'arg2'
parameter: 'arg3'

  這個應該這樣寫:

# Correct version
for x in "$@"; do
 echo "parameter: '$x'"
done
# or better:
for x do
  echo "parameter: '$x'"
done

wingsummer@wingsummer-PC ~ → ./myscript 'arg 1' arg2 arg3
parameter: 'arg 1'
parameter: 'arg2'
parameter: 'arg3'

function foo()

  這在某些 Shell 中有效,但在其他 Shell 中無效。在定義函式時,永遠不要將關鍵字function與括號()組合在一起。

printf "$foo"

  這不是因為引號錯誤,而是因為一個格式字串漏洞。如果$foo不在你的嚴格控制之下,那麼變數中的任何\%字元都可能導致不期望的行為。要始終提供自己的格式字串:

printf %s "$foo"
printf '%s\n' "$foo"

小結

  如果認真學習玩這兩篇,再加上基礎的練習,就可以寫一個 Bash 指令碼了。一定要多加練習,光學不練假把式。當然僅僅學這兩篇頂多是入門,還需要之後的練習和經驗來提升這方面的水平。

羽夏 Bash 簡明教程(下)

相關文章