副程式


簡單的副程式

如果你發現程式當中, 有些五六行以上, 類似的程式片段重複出現好幾次, 就表示你的程式可能可以改進。 把類似的程式片段寫成一個 subroutine 副程式function 函數, 把每次出現時小有變化的部分寫成副程式的 parameter/argument 參數/引數, 這樣就可用一句簡單的副程式呼叫來取代原來整個片段出現的部分。 這樣做有許多好處:

  1. 比較容易除錯 -- 改正副程式內的一個錯誤, 就等於同時改正好幾處的錯誤。
  2. 比較容易增加功能/改善效率 -- 改進副程式內一處的功能, 就等於同時改進好幾處的功能。
  3. 程式變得比較短, 比較漂亮。

試想: 如果一段程式碼重複出現三次, 以後或許也很可能再出現。 如果出現在不同的程式檔裡面, 就更麻煩了: 改進了一處的錯誤/功能, 很可能忘記改另一處。 如果寫成副程式, 不僅可以避免這種狀況, 也可以少打很多字。

執行結束後, 把運算結果傳回呼叫者的, 稱為函數; 純粹用以產生副作用 (例如列印/存檔/播放聲音/...) 沒有傳回值的, 稱為副程式。 在 perl 裡面, 兩者並無特殊分別, 唯一的差別是 return 敘述後面有沒有東西。 我們看一個簡單的 perl 副程式:


        sub sum {
            my ($x, $y) = @_;
            return $x + $y;
        }

Perl 的副程式, 用 sub 定義。 這裡的 sum 是自己任意取的副程式的名字。 Perl 的副程式, 所有參數一律靠 @_ 傳遞。 一般的 perl 副程式, 第一句話就是宣告幾個局部變數, 從 @_ 把參數接收過來。 這樣做有兩個用意:

  1. 參數有名字, 程式比較容易閱讀。
  2. 因為 perl 的參數傳遞是 call-by-reference, 這麼做可以避免不小心更改到呼叫者的變數。 後詳。

進入副程式之後, 就可以將先前學過的, 你熟悉的語法應用上來, 隨便你要算什麼。 我們這個副程式太簡單, 什麼都沒有算。 通常最後一句話是 return, 也就是將值傳回呼叫者去。 然後你就可以在程式其他地方使用這個副程式, 像這樣: print "5 + 3 is ", sum(5,3), "\n"; 請將這兩小段程式碼剪貼到你的編輯器上, 立刻試試看。 舊的 perl 程式, 呼叫副程式時前面要加一個 &, 像這樣: print "5 + 3 is ", & sum(5,3), "\n";。 Q: 如果呼叫 sum(5,3,2) 會發生什麼事?

Perl 不檢查參數的個數, 也不檢查參數的形態。 如果想將 sum 改成可以對任意個數字加總, 可以用一個陣列來接收參數:


        sub sum_all {
            my (@numbers) = @_;
            my ($n, $total);

            foreach $n (@numbers) {
                $total += $n;
            }
            return $total;
        }

從呼叫者的角度來看, 變得很有彈性, 想傳幾個數字進去都可以:


        @data = (2, 3, 5, 7, 11, 13, 17, 19);
        printf "sum_all(\@data) = %d\n", sum_all(@data);
        printf "sum_all(5, 8, \@data) = %d\n", sum_all(5, 8, @data);
        printf "sum_all(\@data, 97, 53, \@data) = %d\n",
            sum_all(@data, 97, 53, @data);

  

Q: 這裡的倒斜線是什麼意思? 提示: 不是 "...的位址"; 實驗一下, 將它拿掉就知道。 它出現在 "..." 裡面, 意義及效果比較像是 " ... \$20 ..."。

你可以傳幾個數字進去, 也可以傳一個陣列, 或是將陣列與純量混著傳。 這一切其實不過就是先前 詳談變數 裡面所提到的規則: list 沒有層次。 同理, 有關 hash 與 list 互相轉換的規則也適用, 例如你可以在一個副程式裡面用一個 hash 來接收參數:


        sub print_like_hash {
            my (%data) = @_;
            my ($key);

            foreach $key (keys %data) {
                print "$key: $data{$key}\n";
            }
        }

  

於是呼叫時可以寫成像是 hash 在設定初始值一樣: print_like_hash("Jan"=>31, "Feb"=>28, "Mar"=>31, "Apr"=>30); 如先前所述, => 其實不過就是逗點, 傳進去的東西不過就是一個 list, 它仍舊被放入 @_ 這個陣列當中; 只不過後來被複製到一個 hash 裡面去了。

文字模式遊戲函式庫

ANSI escape sequence 可以在文字模式下製造特效, 例如移動遊標, 改變顏色等等。 它不受限於一套作業系統, 也不受限於一種程式語言。 瞭解如何在 shell 底下直接手動用 ANSI escape sequence 製造特效後, 就可以自己寫一個簡單的函式庫 sitio

函式庫的最後一句話通常是 1; (其實只要是有定義, 且非 0 非空字串的值就可以了, 總之要傳回 true。)

如何引用函式庫? 在主程式檔案裡面放一句: require "sitio"; 之後就可以任意使用 sitio 提供的那些副程式了。 不過我們先做一個錯誤示範: 請故意將 require 後面的檔案名稱打錯, 或故意將 sitio 放在其他目錄, 總之就是要讓主程式找不到函式庫。 錯誤訊息 說什麼呢?


以下尚未整理


  1. 程式範例:
    1. 15puzzle: 智慧盤遊戲. 用到 sitio 副程式庫.
    2. knight: 騎士走棋盤. 用到 sitio 副程式庫. 註解請參考 C 版本.
  2. 傳入不定個數的參數; 傳出不只一個結果:
    1. 既然接收參數的是 @_ 這個陣列, 就表示在呼叫副程式時, 可以傳入任意個數的參數, 甚至可以把整個陣列或陣列與純量的組合一起傳入.
    2. 在副程式內用 my ( ... ) = @_; 取得參數時, my ( ... ) 的最後可以是一個陣列變數. 它會把 @_ 剩下的所有元素全部吃進去.
    3. 上述各點可以類推至傳回值: 例如在 15puzzle 中, 用 ($row, $col) = & blankpos( ... ) 來呼叫副程式, 而副程式 blankpos 中使用 return ($r, $c) 則可以想像 perl 自動做了: ($row, $col) = ($r, $c) 其中 assignment 左右都可以是 array 或 hash. 也就是說, 傳回值可以不只是一個純量. 但要注意若等號左邊只有一個變數, 小括弧還是不能省略, 否則會接收到最後一個元素! 請試試看這個簡單的程式片段: ($x) = (1,2,3,4); 看看 $x 變成多少? 再把 $x 外面的小括弧拿掉, 看看 $x 又變成多少?
  3. 仔細探究 perlsub(1), 發覺 perl 副程式的參數傳遞其實是 call-by-reference! 如果在副程式當中不以 my 複製參數, 而是直接在 @_ 上操作, 則有機會修改到主程式中傳進來的變數. 所以這個副程式可以用來交換兩個變數的內容:
            sub swap {
                ($_[0], $_[1]) = ($_[1], $_[0]);
            }
        
    
    (但傳回值則沒有這麼複雜.)
  4. 自建程式庫:
    1. 在你的 perl 程式當中先下 require "Xyz.pl", 之後 就可以使用 Xyz.pl 這個副程式庫內定義的副程式.
    2. 如果 perl 找不到你的副程式庫檔放在那個目錄底下, 可以在 require 一句的檔名當中加上完整的路徑 (像這樣: require "./Xyz.pl";) 或在 require 之前先下 use lib ... 以修改 @INC 變數的內容 (詳見 perlvar(1)), 或在 perl 命令列上指定 -I 選項 (詳見 perlrun(1)). 請實驗一下, 看看找不到副程式時印出來的錯誤訊息是什麼。
  5. Recursion (遞迴) 及 activation record
  6. 其他常識:
    1. formal argument/parameter 形式參數: 在副程式的 definition (定義) 的參數列當中出現的東西
    2. actual argument/parameter 實際參數: 在呼叫副程式 (invocation) 的地方出現
    3. "問號-冒號" 是一個三元運算子 (它接受三個參數). A ? B : C 的運算結果可能是 B 也可能是 C. 究竟是 B 還是 C 呢? 要看 A 這個條件是否成立. 如果成立, 則結果就是 B; 要不然結果就是 C.
    4. index 函數可以找子字串出現的位置; rindex 倒過來找; 與這兩個函數相反的是 substr, 可以取出指定位置的子字串.
    5. 一種有用的程式設計風格供參考: 能夠算出來的資訊, 盡量不要存入全域變數. (例如 15puzzle 中的空白位置) 目的不在節省空間, 而在減少資料修改時顧此失彼所造成的不一致 (inconsistence). 多花一點執行時間通常不是很大的問題.
    6. Perl 沒有真正的二維陣列, 但是可以用 hash 或陣列的陣列來模擬.
    7. 要測試一個變數內的值是否已有定義, 可用 defined; 要取消一個變數內的值 (讓它不是 0 也不是空字串, 更不是任何其他值, 就像已宣告但尚未給初始值一樣), 可用 undef.
  7. 作業:
    1. 請重寫 15puzzle, 改以陣列的陣列 (用 reference to anonymous array 的方式儲存) 來做出二維陣列的效果.
    2. 使用 sitio 當中的副程式, 寫一個圈叉棋的遊戲. 使用者可以用 hjkl 等四個鍵移動遊標, (但是你的程式不可以讓遊標移出 3x3 的棋盤範圍之外). 每按一次空間棒, 就打一個圈 (或打一個叉. 總之交替著做就是了), 並檢查是否有贏家出現. 棋盤不必畫得很漂亮, 但至少邊界要標示出來. 盡量在適當的場合使用副程式.