即時餵資料給程式吃


範例程式

以下有三個範例程式, 每個程式各有兩個版本。 兩版本功能完全相同, 只是讀取輸入資料的方式不一樣 -- 「命令列參數版」 從命令列參數讀取資料; 「癡癡地等版」 則是在使用者下了命令, 按了 enter 之後, 癡癡地等著使用者逐列輸入資料。

----------------------------------------------------------------------
左半是 「命令列參數版」         中間是註解      右半是 「癡癡地等版」
----------------------------------------------------------------------

#!/usr/bin/perl -w                              #!/usr/bin/perl -w
# 名稱: length_arg                              # 名稱: length_stdin
# 功用: 數每個輸入字串的長度                    # 功用: 數每個輸入字串的長度
use strict;
my (
    $str,                       # 每個字串
);
foreach $str (@ARGV) {          # 對每個字串..  while (<STDIN>) { 
    print length($str), "\n";   # 印出其長度        print length($_),"\n";
}                                               }

----------------------------------------------------------------------

#!/usr/bin/perl -w                              #!/usr/bin/perl -w
# 名稱: sum_arg                                 # 名稱: sum_stdin
# 功用: 對所有輸入數字加總                      # 功用: 對所有輸入數字加總
use strict;                                     use strict; 
my (                                            my (
    $number,                    # 每個值
    $sum                        # 總和              $sum    
);                                              );
foreach $number (@ARGV) {       # 對每個數字..  while (<STDIN>) {
    $sum += $number;            # 累加              $sum += $_;
}                                               }
print "$sum\n";                                 print "$sum\n";

----------------------------------------------------------------------

#!/usr/bin/perl -w                              #!/usr/bin/perl -w
# 名稱: count_arg                               # 名稱: count_stdin
# 功用: 統計每個字出現幾次                      # 功用: 統計每個字出現幾次
use strict;                                     use strict;
my (                                            my (
    $str,                       # 每個字串          $str,  
    %freq                       # 出現頻率表        %freq  
);                                              );
foreach $str (@ARGV) {          # 對每個字串..  while (<STDIN>) {
                                # (砍列尾 \n)       chomp $_;                   
    ++ $freq{$str};             # 又多了一次!       ++ $freq{$_};
}                                               }
foreach $str (keys %freq) {                     foreach $str (keys %freq) {    
    print "$freq{$str} $str\n";                     print "$freq{$str} $str\n";
}                                               }

----------------------------------------------------------------------

又請參考 「命令列參數」版「癡癡地等」版 的選擇排序程式。

兩種常用的資料輸入方式

為了讓我們寫的程式用起來更有彈性, 我們不喜歡把運算要用到的的所有輸入資料 (例如要把那些數字拿來加總?) 寫死在程式裡, 而是留給使用者在執行程式時再臨時決定。 這類「要執行程式時才決定的資料」, 有兩種簡單的方法傳給程式。 其一是直接讀取命令列上, 命令名稱後面的參數 command line argument (如圖左半部, 想像資料從方框的上面進來)。 Perl 會把使用者敲在命令列上的參數放在 @ARGV 陣列裡。 (熟悉 C 語言的讀者請注意: 在 perl 當中, $ARGV[0] 是第一個參數, 不是命令名稱) 程式執行方式 (以 count_arg 為例):

    ./count_arg apple banana orange ... pineapple peach

若使用者下這個命令來執行我們的程式, 我們在 count_arg 程式內會發現 $ARGV[0] 裡面存著 "apple", $ARGV[1] 裡面存著 "banana", ... Q: 如何知道使用者在命令列上究竟敲入多少個參數? 又, 請印出最後一個參數。 [兩種手動輸入資料方法] 圖案

第二種方法是從 standard input 標準輸入裝置 讀入資料: 在使用者打完命令, 按了 enter 之後, 我們的程式才癡癡地等著他從鍵盤上敲入資料, 每列一筆。 (如圖右半部, 想像資料從方框的左邊進來) 程式語法是 while (<STDIN>) { ... } 如此 perl 會每次 (即迴圈裡面每執行一回之前) 讀入使用者輸入資料的一列, 並且固定將它放在 $_ 這個神奇的變數裡讓我們的程式用。 注意每列最後面有一個換列字元, 習慣上以 chomp 把它刪除。 程式執行方式 (以 count_stdin 為例):

      ./count_stdin
        (提示符號未出現, 但其實並未當掉. 使用者開始輸入資料)
        apple
        banana
        orange
        ...
        pineapple
        peach
        ^d (使用者按著 Ctrl 鍵不放, 再按一下 d 鍵, 表示資料輸入完畢)
           (在 Windows 下則是按 ^z, 不按 ^d)

第二種做法看起來有點無厘頭 ("連個提示符號都沒有, 害我以為程式掛掉了!" ) 但其實在某些場合比第一種方法方便 -- 因為有一些方法可以欺騙程式, 讓它把其他東西當做是 「使用者從鍵盤上輸入的資料」, 而所謂的 「其他東西」, 未必需要真的辛苦地人工敲入! Shell 提供四種特殊符號讓我們改變資料的流向, 方便我們欺騙程式。

首先, 程式原本想要印到螢幕上的東西, 我們可以將它轉而存到一個檔案裡面, 像這樣: length_arg apple banana orange ... pineapple peach > len.txt 這叫做 output redirection 輸出重新導向。 也可以說它的 standard output 標準輸出裝置 被重新導向了。 看到 > 符號時, 你可以在心中想像右邊出去的管線被改灌入地下。

其次, 程式原本想要從鍵盤上讀入的東西, 我們可以轉而從一個既存的檔案裡面抓出來餵它吃, 像這樣: sum_stdin < len.txt 這叫做 input redirection 輸入重新導向。 也可以說它的 standard input 標準輸入裝置 被重新導向了。 看到 < 符號時, 你可以在心中想像左邊進來的管線被改成抽地下水。

第三, 如果前一個程式印到 STDOUT 的結果, 正好就是想拿來當做 STDIN 餵給下一個程式的資料 (像上面兩例恰好如此), 那麼可以不必經過存檔這道手續, 直接就將兩者串起來: length_arg apple banana orange ... pineapple peach | sum_stdin 這個直線符號叫做 pipe (好像水管一樣)。 看到 | 符號時, 你可以在心中想像前一個指令的右邊輸出管線, 直接接到後一個指令的左邊輸入管線。

但是如果下一個程式並不從 STDIN 吃資料, 而是從命令列參數吃資料, 那麼就必須用另外一個方式將兩者串起來: sum_arg $(length_arg apple banana orange ... pineapple peach) 這一對倒引號製造的效果叫做 command substitution (命令結果代換?) 看到 $( ... ) 符號時, 你可以在心中想像前一個指令的右邊輸出管線, 直接接到後一個指令的 上方輸入管線 (而不是左方輸入管線)。 在古代, $( ... ) 的寫法是 ` ... ` 就是倒引號 (ESC 下面那個鍵), 所以這種語法又叫做 back quote substitution。 不過用倒引號, 無法多層串連, 所以建議用 $( ... ) 的語法。 [四種改變資料流的方式] 圖案

以上四種使用方式, 都是 shell 提供的; 至於我們的 perl 程式則全然狀況外, 一直還以為自己仍舊是從命令列參數讀資料/從鍵盤上讀資料/將資料印到螢幕上。 資料可以來自檔案或來自其他程式的輸出, 就不必使用者自己逐一敲入, 是不是非常方便呢? 又, pipe 看起來比 back quote 要簡單一點 (很多書教 pipe; 很少書教 back quote) 所以一個指令如果經常需要輸入大量的資料, 通常會設計成從 STDIN 讀資料, 而不是從命令列參數讀資料, 因為前者的設計比較容易安排將其他程式的輸出或檔案的內容餵給它吃。

不論是 pipe 或是 back quote 都是 它們就像是令積木得以組合的齒一樣, 在 「組合式學習」 當中扮演非常重要的角色。 英文數學好的人, 不在於她能背很多單字與公式, 而在於她能將有限的單字與計算式, 以最適切的方式靈活組合。 用這種方式學電腦, 才是最有長遠投資價值的學習方式。 更多範例請參考 12

Q:

  1. 請圖示以下指令, 並預測會產生何種結果: ./length_stdin < fruit.txt | ./sum_stdin > ans
  2. 請圖示以下指令, 並預測會產生何種結果: ./length_arg $(cat fruit.txt) | ./count_stdin | ./sum_stdin
  3. 請換一種執行方式, 簡化這個命令: length_arg apple banana orange ... pineapple peach | sum_stdin 不要辛苦地自己敲入所有水果名稱, 改令程式從檔案中讀出。 (一種方式是改用 length_stdin; 另一種方式是仍舊使用 length_arg...)

注意: <STDIN> 脫離 while 迴圈單獨使用的狀況比較複雜, 目前請固定將它放在 while 迴圈當中使用。

Q: 與 perl 無關的題外話: 為什麼以下執行方式, 前三者得到相同的結果; 後二者得到相同的結果? 提示: 這些引號不是給 perl 看的, 而是給另外一個程式看的。 在你的 perl 程式接手之前, 它就已經視需要將引號剝掉了。

  1. length_arg good morning
  2. length_arg "good" "morning"
  3. length_arg 'good' 'morning'
  4. length_arg "good morning"
  5. length_arg 'good morning'

Q: length_arg a.txt < b.txt c.txt > d.txt e.txt 請問這句話, 你的 perl 程式 length 看到的 @ARGV 裡面有幾個元素? 結果會發生什麼事?

選擇含有特定字串的那幾列

下面這個程式 pgrep 從標準輸入裝置讀取資料, 然後只將符合搜尋條件的那幾列印出來。 所謂符合搜尋條件, 就是這一列上包含指定的字串。 (用命令列參數指定) 例如下 ./pgrep ' 200 ' 就會將 STDIN 輸入的資料當中, 含有 ' 200 ' 子字串的那幾列印出來。

      #!/usr/bin/perl -w

        die "usage: pgrep pattern" unless $#ARGV >= 0;
        while (<STDIN>) {
            next unless $_ =~ /$ARGV[0]/;
            print $_;
        }

這裡的 next unless ... 如果一下腦筋轉不過來, 可以改寫兩次, 變成比較簡單直接的語法, 就比較容易懂了: 先改成 unless (...) { next; } 再改成 if (not ...) { next; }

這個程式其實近乎等同於 shell 底下的 grep。 它甚至可以拿來搜尋複雜的字串, 例如 ./pgrep 's9214[01].[13579]' 印出所有包含 "四技二A 同學學號" 的那幾列。 這些表達複雜字串規則的特殊符號叫做 Regular Expressions

取得每列第 k 個欄位

下面這個程式 get_field 假設標準輸入裝置輸入的每列資料, 都以冒號分隔所有欄位, 像這樣:

        root:x:0:0:root:/root:/bin/bash
        bin:x:1:1:bin:/bin:/bin/sh
        ...
        ckhung:x:1006:100:洪朝貴,T2.907,x4271:/home/ckhung:/bin/bash

(順便一提: 這是 /etc/passwd 檔的內容, 別人 finger 你的時候, 系統就是在這裡找到你的個人資料。 如果你希望別人 finger 你的時候, 秀出比較酷的資訊, 可以下 chfn 指令。 又, 題外話: 系統其實已經有一個類似的指令, 請見 cut(1) )

get_field 會抓出第 1 個欄位的資料。 (注意欄位號碼從第 0 個欄位數起。) 又, 如果你想抓其他欄位的話, 可以在命令列上放一個數字當做參數, 像這樣: ./get_field 4, 則它會抓出第 4 個欄位, 也就是 "root", "bin", ... "洪朝貴,T2.907,x4271" 等等。

      #!/usr/bin/perl -w

        use strict;

        my ($which, $i, $n, $c);

        $which = ($#ARGV >= 0) ? $ARGV[0] : 1;

        while (<STDIN>) {
            chomp $_;
            $n = 0;             # 到目前為止已經看到幾個冒號了?
            for ($i=0; $i<length($_); ++$i) {
                $c = substr($_, $i, 1);
                if ($c eq ":") {
                    ++$n;
                    last if ($n > $which);
                } else {
                    print $c if ($n == $which);
                }
            }
            print "\n";
        }

這整個程式其實可以用一句話來取代。 例如 ./get_field 4 可以用 perl -ne 'print((split /:/)[4], "\n")' /etc/passwd 來取代。

常用運算子

邏輯運算子 and or not && || ! : Perl 的邏輯運算規則與 C 相同 -- 都是採取 short-circuit evaluation, 如果一長串 and or 的前半部算完時, 已經可以知道答案, 它就會偷懶地省略計算後半部的工作。 例如 if ($candies/$children < 5 and $children > 2) { ... } 有 bug (萬一要是 $children 等於零怎麼辦?); 如果改成 if ($children > 2 and $candies/$children < 5) { ... } 就沒有問題了。

處理字串的運算子:

  1. . 可以連接字串, 例如 "concate" . "nation" 的結果是 "concatenation"
  2. x 可以複製字串, 例如 "Great! " x 3 的結果是 "Great! Great! Great! "
  3. 比較字串用 lt (less than), le (less equal), gt (greater than), ge (greater equal), eq (equal), ne (not equal).
  4. 搜尋子字串 $x =~ /.../

更詳細的討論, 請見 perlop(1) (<== 這個的意思是下 man 1 perlop)

簡單流程控制

與一般語言一樣, perl 提供以下幾個一看就懂的流程控制敘述:

  1. if (EXPR) { ... }
  2. if (EXPR) { ... } else { ... }
  3. if (EXPR) { ... } elsif (EXPR) { ... } elsif (EXPR) { ...} ... else { ... }
  4. while (EXPR) { ... }
  5. 比較特別的是 unless (EXPR) { ... } "除非"。 如果頭昏, 可以將它翻譯成 if (not (EXPR)) { ... }

其中的 EXPR 是判斷條件, 例如 $i<$n$name eq "Larry Wall"$j<$i and $data[$j]>$min 等等。 熟悉 C 語言的讀者請注意: 即使大括弧內只有一句話, 大括弧還是不能省略。 上述所有句形都一樣。

另外還有先前學過的 for, 其實可以用 while 來模擬; foreach 可以用 for 來模擬.

有時需要打斷迴圈正常流程:

  1. next 這個迴圈的這一次剩下的部分不做了, 直接進入迴圈的下一次. (類似 C 語言中的 continue)
  2. last 完全跳出這個迴圈, 不但這一次剩下的部分不做了, 不論剩下多少次都不做了. (類似 C 語言中的 break)
  3. 這兩個命令還可以跳出外層的迴圈, 不像 C 的 continue 與 break, 只限於跳出最內層的迴圈. 詳見 perlsyn(1) 中談論迴圈的 label 的部分.

Perl 裡面比較特別的是 "英文式" 寫法: 在英文裡, 條件子句可以寫在主要子句前面, 而意思其實沒有變。 例如: "Take the left trail if you hear a dog barks." 在 perl 裡面, 如果主要子句只有短短一句話, 那麼也可以這樣寫. 例如:

     print $i if $i > 0;          # $i 大於零才要印
       print $i unless $i > 0;      # $i 大於零就不印; 其他情況都印
        

更詳細的討論, 請見 perlsyn(1) (<== 這個的意思是下 man 1 perlsyn)

其他常識

  1. 遇到沒有看過的函數, 可查手冊 perlfunc(1) 或 perlop(1) 前面那篇是 perl 函數手冊; 後面這篇是 perl 運算子手冊。 因為在 perl 裡面, 有些看起來像函數的東西其實是運算子, 例如 eq, x, cmp, and, ... 等等。 比方說要查 perl 內的 printf 怎麼用, 可以: 下 man 1 perlfunc 命令, 然後打: 斜線 "/", 乘方 "^", 七個空格, "printf", 然後按 Enter。 見 regular expression 範例)
  2. 一個變數, 如果未曾設定初始值, 那麼它的初始值就是 undef.
  3. undef 值出現在數學運算式當中時, perl 把它當做 0 來看待; 出現在字串運算式當中, perl 把它當做空字串來看待.
  4. 三元運算子 (測試) ? (成功值) : (失敗值) 先判斷 (測試) 是否成立, 若成立, 則傳回 (成功值), 不然就傳回 (失敗值)
  5. substr 函數可以取出字串的一小段。 (指定從第幾個字元開始取, 總共要取幾個字元) 注意: 字元從第 0 個開始數起。 以後學 split 函數或 regular expression 程式可以簡潔許多, 你會發覺幾乎不太需要用到 substr。
  6. 這個單元裡面的兩個小程式合起來, 其實就已經可以做到 這當中 的 "分析流量" 一例的前半部: ./get_field < access_log | ./count_stdin, 雖然沒有文中的例子那麼簡潔, 至少比用其他語言寫要簡單多了。 再過幾個星期, 我們的程式可以寫得更短, 更少。 有的時候, 外觀越是輕巧簡單樸素, 其實越是內容充實豐富精彩的表現。 (很含蓄地老王賣瓜...)

作業

  1. (複習用 for 迴圈模擬 foreach 迴圈) 請將各個 *_arg 範例程式改用 for 迴圈重寫. (自找麻煩)
  2. 從命令列上讀入一個字串 x, 然後又癡癡地等著從 STDIN 逐列讀入數字 (每列一個), 並根據 x 來決定是否要將這列的數字印出來. 如果 x 是 "odd" 就只印單數; 如果 x 是 "even" 就只印偶數.
  3. 從命令列上讀入兩個數字 x 與 y, 然後又癡癡地等著從 STDIN 逐列讀入字串 (每列一個), 把字串 x 個字元, 重複印 y 次, 像這樣:
            perl hw3 2 5
            Tuesday
            eeeee
            morning
            rrrrr
            band
            nnnnn
    
    提示: 用 substr 函數取得子字串。 詳見 man perlfunc。
  4. 從命令列上讀入數個數字 x, y, z 等等, 然後又癡癡地等著從 STDIN 逐列讀入數字 (每列一個) w。 判斷 w 能否分別被 x, y, z 等等整除, 若可以, 就印 o, 若不行, 就印 x。 並且, w 若能被所有的 x, y, z 整除, 就印 "good"。 像這樣:
            perl hw4 10 6 4
            48
            xoo
            50
            oxx
            30
            oox
            120
            ooo good
            720
            ooo good
            20
    	oxo
    	52
    	xxo
    
  5. 從命令列上讀入 m 個數字 A1, A2, ... Am , 又癡癡地等著從 STDIN 讀入 n 個數字 B1, B2, ... Bm, 印出 Ai 和 Bj 的乘積 (總共有 m * n 個結果, 請排列整齊成一個表). 請利用你寫的這個程式來產生一個九九乘法表. (不准修改程式, 要想辦法 使用 你既有的程式.)
  6. ssort_stdin 改用你最熟悉的排序方法重新寫過.

心得: perl 程式怎麼寫怎麼對, 實在有點難 debug, 請不要覺得 "連這麼簡單的程式都寫這麼久, 真是灰心!" 剛開始學 perl 時, 花很多時間 debug 是正常的; 切記執行時要用 perl -w ...