<譯> Kleisli 範疇

原文見 http://bartoszmilewski.com/20...c++

-> 上一篇:『範疇,可大可小算法

日誌的複合

你已經見識瞭如何將類型與純函數塑造爲一個範疇。我還提到過,在範疇論中,有辦法構造反作用或非純函數。如今有一個像這樣的例子:具備運行日誌的函數。這種東西,用命令式語言能夠經過對一些全局狀態的修改來實現,像這樣:編程

string logger;

bool negate(bool b) {
     logger += "Not so! ";
     return !b;
 }

這不是一個純函數,由於它的記憶版本(見『類型與函數』的第 1 個挑戰題)沒法產生日誌。這個函數有反作用segmentfault

若是是併發的複雜狀況,現代的編程理念建議你儘量離全局可變的狀態遠一些。此外,永遠不要將這樣的代碼放在庫裏。併發

不過,只要顯式的傳送日誌,就能夠將這個函數變成純函數。如今爲它增長一個字符串參數,並將本來的返回值與更新後的日誌字符串打包爲 pair 類型:app

pair<bool, string> negate(bool b, string logger) {
     return make_pair(!b, logger + "Not so! ");
}

這個函數是純的,它沒有反作用。只要你給它相同的輸入,它就能產生相同的輸出。若有必要,它也能被記憶。不過,考慮到日誌的累積性,你不得不收集這個函數運行狀況的所有歷史,每調用它一次,就產生一條備忘,例如:編程語言

negate(true, "It was the best of times. ");

ide

negate(true, "It was the worst of times. ");

等等。函數

對於庫函數,這不是很好的接口。函數的調用者能夠忽略所返回類型中的字符串,所以返回類型不會形成太多大的負擔,可是調用者被強迫傳遞一個字符串做爲輸入,這可能很是不方便。spa

有沒有辦法能夠消除這些煩人的東西?有沒有辦法能夠將咱們所關心的東西分離出來?在這個簡單的示例中,negate 的主要任務是將一個布爾值轉換爲另外一個布爾值。日誌是次要的。儘管日誌信息對於這個函數而言是特定的,可是將信息聚集到一個連續的日誌這一任務是可單獨考慮的。咱們依然想讓這個函數生成日誌信息,可是能夠減輕一下它的負擔。如今有一個折中的解決方案:

pair<bool, string> negate(bool b) {
     return make_pair(!b, "Not so! ");
}

這樣,日誌信息的聚集工做就被轉移至函數的當前調用以後且在下一次被調用以前的時機。

爲了看看這種方式如何工做,咱們用一個更現實一些的示例。咱們有一個將小寫字符串變成大寫字符串的函數,其類型是從字符串到字符串:

string toUpper(string s) {
    string result;
    int (*toupperp)(int) = &toupper; // toupper is overloaded
    transform(begin(s), end(s), back_inserter(result), toupperp);
    return result;
}

還有一個函數,可將字符串在空格處斷開,將其分割爲字符串向量:

vector<string> toWords(string s) {
    return words(s);
}

實際上,字符串分割的任務是由一個輔助函數 words 完成的:

vector<string> words(string s) {
    vector<string> result{""};
    for (auto i = begin(s); i != end(s); ++i)
    {
        if (isspace(*i))
            result.push_back("");
        else
            result.back() += *i;
    }
    return result;
}

問題來了,如今咱們將函數 toUppertoWords 修改一下,讓它們的返回值肩負日誌信息。

piggyback

下面就來『裝幀』這些函數的返回值。能夠採用泛型方式來作這件事,首先定義一個 Writer 模板,它其實是一個序對模板,這個序對的第一個元素是類型爲 A 的值,第二個元素是字符串:

template<class A>
using Writer = pair<A, string>;

接下來是兩個通過裝幀的函數:

Writer<string> toUpper(string s) {
    string result;
    int (*toupperp)(int) = &toupper;
    transform(begin(s), end(s), back_inserter(result), toupperp);
    return make_pair(result, "toUpper ");
}

Writer<vector<string>> toWords(string s) {
    return make_pair(words(s), "toWords ");
}

咱們想將這兩個函數複合爲一個一樣通過裝幀的函數,這個函數的功能就是將一個小寫字串轉化爲大寫字串,而後將其分割爲向量,同時產生這些運算的日誌。咱們的作法是:

Writer<vector<string>> process(string s) {
    auto p1 = toUpper(s);
    auto p2 = toWords(p1.first);
    return make_pair(p2.first, p1.second + p2.second);
}

如今咱們已經完成了目標:日誌的聚集再也不由單個的函數來操心。這些函數各自產生各自的消息,而後在外部彙總爲一個更大的日誌。

若是整個程序都採用這樣的風格來寫,那麼大量重複性的代碼就會變成惡夢。可是咱們是程序猿,咱們知道如何處理重複的代碼:對它進行抽象!然而,這並不是是普通的抽象,而是對函數的複合自己進行抽象。因爲複合是範疇論的本質,所以在動手以前,咱們先從範疇的角度分析一下這個問題。

Writer 範疇

對那幾個函數的返回類型進行裝幀,其意圖是爲了讓返回類型肩負着一些有用的附加功能。這一策略至關有用,下面將給出更多的示例。起點仍是常規的的類型與函數的範疇。咱們將類型做爲對象,與之前有所不一樣的是,如今將裝幀過的函數做爲態射了。

例如,假設咱們要裝幀從 intboolisEven 函數,而後將裝幀後的函數做爲態射。儘管裝幀後的函數返回了一個序對:

pair<bool, string> isEven(int n) {
     return make_pair(n % 2 == 0, "isEven ");
}

可是,咱們依然認爲它是從 intbool 的態射。

根據範疇法則,可將這種態射與另外一種從 bool 到任何類型的態射進行復合。例如,咱們應該可以將它與此前定義的 negate 複合:

pair<bool, string> negate(bool b) {
     return make_pair(!b, "Not so! ");
}

可是,顯然沒法像常規的函數那樣去複合這樣的兩個態射,由於它們的輸入/輸出不匹配。它們的複合只能像下面這樣實現:

pair<bool, string> isOdd(int n) {
    pair<bool, string> p1 = isEven(n);
    pair<bool, string> p2 = negate(p1.first);
    return make_pair(p2.first, p1.second + p2.second);
}

咱們將這種新的範疇中兩個態射的複合法則總結爲:

  1. 執行與第一個態射所對應的裝幀函數,獲得第一個序對;
  2. 從第一個序對中取出第一個元素,將這個元素傳給與第二態射對應的裝幀函數,獲得第二個序對;
  3. 將兩個序對中的第二個元素(字符串)鏈接起來;
  4. 將計算結果與鏈接好的字符串捆綁起來做爲序對返回。

若想將這種複合抽象爲 C++ 中的高階函數,必須根據與咱們的範疇中的三個對象相對應的三種類型構造一個參數化模板。這個函數應該接受能遵照上述複合法則的兩個可複合的裝幀函數,返回第三個裝幀函數:

template<class A, class B, class C>
function<Writer<C>(A)> compose(function<Writer<B>(A)> m1, 
                               function<Writer<C>(B)> m2)
{
    return [m1, m2](A x) {
        auto p1 = m1(x);
        auto p2 = m2(p1.first);
        return make_pair(p2.first, p1.second + p2.second);
    };
}

如今,咱們再回到以前的示例,用這個新的模板去實現 toUppertoWords 的複合:

Writer<vector<string>> process(string s) {
   return compose<string, string, vector<string>>(toUpper, toWords)(s);
}

傳遞給 compose 模板的類型依然伴隨着大量的噪音。對於支持 C++14 的編譯器,它支持具備返回類型推導功能的泛型匿名函數(此處代碼歸功於 Eric Niebler):

auto const compose = [](auto m1, auto m2) {
    return [m1, m2](auto x) {
        auto p1 = m1(x);
        auto p2 = m2(p1.first);
        return make_pair(p2.first, p1.second + p2.second);
    };
};

利用這個新的 compose,可將 process 簡化爲:

Writer<vector<string>> process(string s){
   return compose(toUpper, toWords)(s);
}

事情還沒完。雖然在這個新的範疇裏已經定義了態射的複合,可是恆等態射是什麼?這些恆等態射確定不是常規意義上的恆等態射!它們必須是一個從(裝幀以前的)類型 A 到(裝幀以後的)類型 A 的的態射,即:

Writer<A> identity(A);

對於複合而言,它們的行爲必須像 unit。若要符合上面的態射覆合的定義,那麼這些恆等態射不該該修改傳給它的參數,而且對於日誌它們僅貢獻一個空的字符串:

template<class A>
Writer<A> identity(A x) {
    return make_pair(x, "");
}

不難確信,咱們所定義的這個範疇是一個合法的範疇。特別是,咱們所定義的態射的複合是遵照結合律的,雖然這可有可無。若是你只關心每一個序對的第一個元素,這種複合就是常規的函數複合。第二個元素會被鏈接起來,而字符串的鏈接也是遵照結合律的。

敏銳的讀者可能會注意到,這種構造適用於任何幺半羣,而不只僅是字符串幺半羣。咱們能夠在 compose 中使用 mappend,在 identify 中使用 mempty。這樣作,實際上能夠將咱們從基於字符串的日誌中解脫出來。優秀的庫級 Writer 應該可以標識讓庫可以工做的最低限度的約束——在此處,就是一個日誌庫,只須要日誌擁有幺半羣般的性質。

Haskell 中的 Writer

一樣的事,在 Haskell 中作起來要簡約一些,並且也能獲得編譯器的不少幫助。咱們從定義 Writer 類型開始:

type Writer a = (a, String)

這裏,我定義了一個類型別名,等價與 C++ 中的 typedefusingWriter 的類型被類型變量 a 參數化了,它等同於 aString 構成的序對。序對的語法很簡單:用逗號隔開兩個元素,外圍套上括號。

態射就是從任意類型到 Writer 類型的函數:

a -> Writer b

咱們將複合聲明爲一個可愛的中綴運算符,可將其稱爲『魚』:

(>=>) :: (a -> Writer b) -> (b -> Writer c) -> (a -> Writer c)

這個函數接受兩個自身也是函數的參數,返回一個函數。第一個參數的類型是 (a -> Writer b),第二個參數的類型是 (b -> Writer c),返回值是 (a -> Writer c)

這個中綴運算法的定義以下,m1m2 是它的參數:

m1 >=> m2 = \x -> 
    let (y, s1) = m1 x
        (z, s2) = m2 y
    in (z, s1 ++ s2)

返回的是一個具備單參數 x 的匿名函數。在 Haskell 中,匿名函數就是一個反斜槓,一個斷了左腿的 λ。

let 表達式可讓你聲明輔助變量。在本例中,輔助變量是與 m1 的返回值相匹配的序對變量 (y, s1),同理,還有 (z, s2)

在 Haskell 中,序對的模式匹配很尋常,它不使用咱們在 C++ 中所習慣的訪問器(Accesor)。除了這一點,這兩種語言所實現的序對基本上是大同小異的。

整個 let 表達式的結果由 in 從句產生,結果就是 (z, s1 ++ s2)

還得爲這個範疇定義一個恆等態射,我將這個態射命名爲 return,至於爲什麼這樣命名,之後你就知道了。

return :: a -> Writer a
return x = (x, "")

爲了示例的完整性,咱們還得定義 upCasetoWords 的 Haskell 版本:

upCase :: String -> Writer String
upCase s = (map toUpper s, "upCase ")

toWords :: String -> Writer [String]
toWords s = (words s, "toWords ")

map 函數至關於 C++ 的 transform。它將 toUpper 函數做用於 s 中的每一個字符。words 是 Haskell 標準庫(Prelude library)中已經定義了的函數。

最後,在小魚運算符的幫助下,給出函數的複合函數:

process :: String -> Writer [String]
process = upCase >=> toWords

Kleisli 範疇

你可能已經猜到了,其實我並不是當場就發明了這個範疇。它實際上是一個被稱爲 Kleisli 範疇的示例。Kleisli 範疇是創建於單子之上的範疇。在此,咱們依然不討論單子,我只是想讓你看看單子都能幹些什麼。對於咱們有限的目的,一個 Kleisli 範疇擁有編程語言的類型,它們是這個範疇中的對象。從類型 A 到類型 B 的態射是從 A 到由 B 的派生類型(裝幀後的 B)的函數。每一個 Kleisli 範疇都定義了相應的態射的複合運算,以及可以支持這種複合運算的恆等態射。(『裝幀』是個不嚴謹的說法,它至關於範疇論中的自函子,這一點之後咱們就知道了。)

我所用的這個特定的單子是本文中所涉及的範疇的基礎,它叫 Writer 單子,專門用於函數執行狀況的跟蹤記錄。它也是反作用被嵌入到純計算過程這種通常性機制的一個範例。以前你已經見識了,咱們能夠將編程語言的類型與函數構建爲集合的範疇(忽略底的存在)。在本文中,咱們將這個模型擴展爲一個稍微有些不一樣的範疇,其態射是通過裝幀的函數,態射的複合所作的工做不只僅是將一個函數的輸出做爲另外一個函數的輸入,它作了更多的事。這樣,咱們就多了一個能夠擺弄的自由度:這種複合自己。對於傳統上使用命令式語言而且經過反作用實現的程序,這種複合運算可以給出簡單的指稱語義。

挑戰

一個函數,若是它不是爲了它的參數的全部可能取值而定義,那麼這個函數就叫作偏函數。它不是數學意義上的函數,所以它不適合標準的範疇論模型。不過,它可以被裝幀成返回 optional 類型:

template<class A> class optional {
    bool _isValid;
    A    _value;
public:
    optional()    : _isValid(false) {}
    optional(A v) : _isValid(true), _value(v) {}
    bool isValid() const { return _isValid; }
    A value() const { return _value; }
};

做爲示例,在此給出通過裝幀的函數 safe_root 的實現:

optional<double> safe_root(double x) {
    if (x >= 0) return optional<double>{sqrt(x)};
    else return optional<double>{};
}

如下是挑戰:

  1. 爲偏函數構造 Kleisli 範疇(定義複合與恆等)。
  2. 實現裝幀函數 safe_reciprocal,若是參數不等於 0,它返回一個參數的倒數。
  3. 複合 safe_rootsafe_reciprocal,產生 safe_root_reciprocal,使得後者可以在任何狀況下都能計算 sqrt(1/x)

致謝

感謝 Eric Niebler 閱讀了草稿,並利用 C++14 的新功能來驅動類型推導,從而能給出了更好的 compose 實現。得益於此,我砍掉了整整一節的舊式模板的魔幻代碼,它們使用類型 trait 作了相同的事。排出毒素,一身輕鬆!也很是感謝 Gershom Bazerman 有用的評論,幫助我澄清了一些要點。

-> 下一篇:『積與餘積

相關文章
相關標籤/搜索