[Perl] 以 Monad 來達成 safe navigation

作者:   發佈於: ,更新於:   #perl #monad

在 reddit.com/r/perl 板上看到 pmz 問了這樣子的一個問題:

XML::Twig, get value (or not) without dying

Currently I do:

if (defined $elt->first_child('addr')->first_child('postalCode')) {
  $patient{patient_postal_code} = $elt->first_child('addr')->first_child('postalCode')->text ;
}

because if I don't check for "defined" and the resulting value is null , it dies.

原文連結: https://www.reddit.com/r/perl/comments/1492sc1/xmltwig_get_value_or_not_without_dying/

這一方面是在針對 XML::Twig 模組的用法提問,另一方面是在問:當 first_child('addr') 傳回 undef 時,表示在目前 XML 文件樹中沒有 addr 元素,而後o方的連環呼叫 first_child('postalCode') 之處就會讓程式執行中斷,因為原本預期是物件的地方,也有可能是 undef。那有沒有能讓連環呼叫不會使程式失敗,並且在遇到 undef 時,讓整串算式的結果成為 undef 的辦法?

如果把題目重新描述得較抽象一些的話,就是:假設有一類別,其下有方法 a()b()c()。這三個方法可能會傳回 undef 或同一類別的物件。今有一物件 $o 屬此類別,考慮此連環呼叫式:

$res = $o->a()->b()->c();

此式在 a()b() 傳回 undef 時,就會讓程式中斷,並會出現像這樣的錯誤訊息:

Can't call method "c" on an undefined value

亦即:無法對 undef (undefined value) 呼叫 "c" 這個方法。這是當然的,因為 undef 並非物件。

試問:有何手段能改寫此式,能避免前述錯誤之發生,使 $resa()b()c() 任一者傳回 undef 時就算得 undef,且在全無 undef 情況下,又能正確算得 c() 的傳回值?

在一些語言中,這可輕易得透過使用 safe-navigation operator 或 optional chaining operator 來解決。例如 javascript 或 kotlin 中的 ?.

res = o.a()?.b()?.c();

又例如 raku 語中的 .?:

$res = $o.a().?b().?c();

但直到目前 perl 5.38 為止,在 perl 語言中尚未出現同等作用的算符。

有個直觀的改寫方式是像這樣:

$res_a = $o->a();
$res_b = $res_a && $res_a->b();
$res   = $res_b && $res_b->c();

但在讓程式變長、用了多個臨時變數而讓程式碼變得稍微不好讀之外,這改寫法還不通用,碰到類似結構、名稱不同的式子就得手動從頭改寫。實在算不上是好辦法。

此外,有個超間單的改寫方法:

$res = eval { $o->a()->b()->c() };

但是,這個寫法的副作用,就是所有例外事件通通都會被忽略,雖然簡單好改,但其副作用很強。不見得在各種情境都適合。

在此提供一個以 Monad Design Pattern 來達成得的一個解法。

改寫方法如下:

$res = SafeNav->wrap($o) ->a()->b()->c() ->unwrap();

其中 SafeNav 的定義為:

use v5.36;
package SafeNav {
    sub wrap ($class, $o) { bless \$o, $class }
    sub unwrap ($self)    { $$self            }

    sub AUTOLOAD {
        our $AUTOLOAD;
        my $method = substr $AUTOLOAD, 2 + rindex($AUTOLOAD, '::');

        my ($self, @args) = @_;

        # [a]
        (defined $$self) ?
            __PACKAGE__->wrap( $$self -> $method(@args) ) :     # [a.1]
            $self;                                              # [a.2]
    }

    sub DESTROY {}
};

這類別基本上是把所有純量值都包裝成物件。SafeNav 類別定義有 AUTOLOAD 這個特殊用途的函式,使其物件可接受所有方法呼叫。就算包著的值是 undef 方法呼叫也都不會失敗,而是會進入 AUTOLOAD 函式中。

於是在 [a] 處就可進行核心處理:若自己包著的值不是 undef,就對其呼叫原方法([a.1] 處)。倘若自己包著的值已經是 undef 了,那繼續躺平做自己(?)就可以了([a.2] 處)。

多虧有 AUTOLOAD 機制,原連環呼叫 ->a()->b()->c() 的部份是原樣保留著的。在此並列、比較一下:

$res = $o ->a()->b()->c();
$res = SafeNav->wrap($o) ->a()->b()->c() ->unwrap();

前後的 wrap()unwrap() 算是明確界定了 SafeNav 類別在此式中的作用範圍。過了 unwrap() 之後如果還繼續串著更多方法呼叫,就一定不是被包在 SafeNav 裡面的。

這麼一來,會被「妥善忽略」的,就只有在碰到 undef 這個值的時候。若在 a、b、c 三個方法內發生了任何例外事件,程式還是能正確地被中止。並不會有太多副作用。在本文內提到的三種改寫辦法裡面,算是既通用,又不太囉唆的一種。