函數式編程 - 有趣的Monoid(單位半群)

前言

Monoid(中文:單位半群,又名:幺半群),一個來源于數學的概念;得益于它的抽象特性,Monoid在函數式編程中起著較為重大的作用。

本篇文章將會以工程的角度去介紹Monoid的相關概念,并結合幾個有趣的數據結構(如MiddlewareWriter)來展現Monoid自身強大的能力及其實用性。

Semigroup(半群)

在開始Monoid的表演之前,我們首先來感受一下Semigroup(半群),它在 維基百科上的定義 為:

集合S和其上的二元運算·:S×S→S。若·滿足結合律,即:?x,y,z∈S,有(x·y)·z=x·(y·z),則稱有序對(S,·)為半群,運算·稱為該半群的乘法。實際使用中,在上下文明確的情況下,可以簡略敘述為“半群S”。

上面的數學概念比較抽象,理解起來可能比較麻煩。下面結合一個簡單的例子來通俗說明:

對于自然數1、2、3、4、5、...而言,加法運算+可將兩個自然數相加,得到的結果仍然是一個自然數,并且加法是滿足結合律的:(2 + 3) + 4 = 2 + (3 + 4) = 9。如此一來我們就可以認為自然數和加法運算組成了一個半群。類似的還有自然數與乘法運算等。

通過以上的例子,半群的概念非常容易就能理解,下面我通過Swift語言的代碼來對Semigroup進行實現:

// MARK: - Semigroup

infix operator <> : AdditionPrecedence

protocol Semigroup {
    static func <> (lhs: Self, rhs: Self) -> Self
}

協議Semigroup中聲明了一個運算方法,該方法的兩個參數與返回值都是同一個實現了半群的類型。我們通常將這個運算稱為append

以下為StringArray類型實現Semigroup,并進行簡單的使用:

extension String: Semigroup {
    static func <> (lhs: String, rhs: String) -> String {
        return lhs + rhs
    }
}

extension Array: Semigroup {
    static func <> (lhs: [Element], rhs: [Element]) -> [Element] {
        return lhs + rhs
    }
}

func test() {
    let hello = "Hello "
    let world = "world"
    let helloWorld = hello <> world
    
    let one = [1,2,3]
    let two = [4,5,6,7]
    let three = one <> two
}

Monoid(單位半群)

定義

Monoid本身也是一個Semigroup,額外的地方是它多了單位元,所以被稱作為單位半群單位元維基百科上的定義 為:

在半群S的集合S上存在一元素e,使得任意與集合S中的元素a都符合 a·e = e·a = a

舉個例子,在上面介紹Semigroup的時候提到,自然數跟加法運算組成了一個半群。顯而易見的是,自然數0跟其他任意自然數相加,結果都是等于原來的數:0 + x = x。所以我們可以把0作為單位元,加入到由自然數和加法運算組成的半群中,從而得到了一個單位半群。

下面就是Monoid在Swift中的定義:

protocol Monoid: Semigroup {
    static var empty: Self { get }
}

可以看到,Monoid協議繼承自Semigroup,并且用empty靜態屬性來代表單位元

我們再為StringArray類型實現Monoid,并簡單演示其使用:

extension String: Monoid {
    static var empty: String { return "" }
}

extension Array: Monoid {
    static var empty: [Element] { return [] }
}

func test() {
let str = "Hello world" <> String.empty // Always "Hello world"
let arr = [1,2,3] <> [Int].empty // Always [1,2,3]
}

組合

對于有多個Monoid的連續運算,我們現在寫出來的代碼是:

let result = a <> b <> c <> d <> e <> ...

Monoid的數量居多,又或者它們是被包裹在一個數組或Sequence中,我們就很難像上面那樣一直在寫鏈式運算,不然代碼會變得復雜難堪。此時可以基于Sequencereduce方法來定義我們的Monoid串聯運算concat

extension Sequence where Element: Monoid {
    func concat() -> Element {
        return reduce(Element.empty, <>)
    }
}

如此一來我們就可以很方便地為位于數組或Sequence中的若干個Monoid進行串聯運算:

let result = [a, b, c, d, e, ...].concat()

條件

在開始討論Monoid的條件性質前,我們先引入一個十分簡單的數據結構,其主要是用于處理計劃中即將執行的某些任務,我把它命名為Todo

struct Todo {
    private let _doIt: () -> ()
    init(_ doIt: @escaping () -> ()) {
        _doIt = doIt
    }
    func doIt() { _doIt() }
}

它的使用很簡單:我們先通過一個即將要處理的操作來構建一個Todo實例,然后在適當的時機調用doIt方法即可:

func test() {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    // Wait a second...

    sayHello.doIt()
}

這里還未能體現到它的強大,接下來我們就為它實現Monoid

extension Todo: Monoid {
    static func <> (lhs: Todo, rhs: Todo) -> Todo {
        return .init {
            lhs.doIt()
            rhs.doIt()
        }
    }

    static var empty: Todo {
        // Do nothing
        return .init { }
    }
}

append運算中我們返回了一個新的Todo,它需要做的事情就是先后完成左右兩邊傳入的Todo參數各自的任務。另外,我們把一個什么都不做的Todo設為單位元,這樣就能滿足Monoid的定義。

現在,我們就可以把多個Todo串聯起來,下面就來把玩一下:

func test() {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    let todo = sayHello <> likeSwift <> likeRust

    todo.doIt()
}

有時候,任務是按照某些特定條件來判斷是否被執行,比如像上面的test函數中,我們需要根據特定的條件來判斷是否要執行三個Todo,重新定義函數簽名:

func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool)

為了能夠實現這種要求,通常來說有以下兩種較為蛋疼的做法:

// One
func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    var todo = Todo.empty
    if shouldSayHello {
        todo = todo <> sayHello
    }
    if shouldLikeSwift {
        todo = todo <> likeSwift
    }
    if shouldLikeRust {
        todo = todo <> likeRust
    }

    todo.doIt()
}

// Two
func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    var arr: [Todo] = []
    if shouldSayHello {
        arr.append(sayHello)
    }
    if shouldLikeSwift {
        arr.append(likeSwift)
    }
    if shouldLikeRust {
        arr.append(likeRust)
    }
    arr.concat().doIt()
}

這兩種寫法都略為復雜,并且還引入了變量,代碼一點都不優雅。

這時,我們就可以為Monoid引入條件判斷:

extension Monoid {
    func when(_ flag: Bool) -> Self {
        return flag ? self : Self.empty
    }

    func unless(_ flag: Bool) -> Self {
        return when(!flag)
    }
}

when方法中,如果傳入的布爾值為true,那么此方法將會原封不動地把自己返回,而如果傳入了false,函數則返回一個單位元,相當于丟棄掉現在的自己(因為單位元跟任意元素進行append運算結果都是元素本身)。unless方法則只是簡單地互換一下when參數中的布爾值。

現在,我們就能優化一下剛剛test函數的代碼:

func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Todo {
        print("Hello, I'm Tangent!")
    }

    let likeSwift = Todo {
        print("I like Swift.")
    }

    let likeRust = Todo {
        print("And also Rust.")
    }

    let todo = sayHello.when(shouldSayHello) <> likeSwift.when(shouldLikeSwift) <> likeRust.when(shouldLikeRust)
    todo.doIt()
}

比起之前的兩種寫法,這里優雅了不少。

一些實用的Monoid

接下來我將介紹幾個實用的Monoid,它們能用在日常的項目開發上,讓你的代碼可讀性更加簡潔清晰,可維護性也變得更強(最重要是優雅)。

Middleware(中間件)

Middleware結構非常類似于剛剛在文章上面提到的Todo:

struct Middleware<T> {
    private let _todo: (T) -> ()
    init(_ todo: @escaping (T) -> ()) {
        _todo = todo
    }
    func doIt(_ value: T) {
        _todo(value)
    }
}

extension Middleware: Monoid {
    static func <> (lhs: Middleware, rhs: Middleware) -> Middleware {
        return .init {
            lhs.doIt($0)
            rhs.doIt($0)
        }
    }

    // Do nothing
    static var empty: Middleware { return .init { _ in } }
}

比起TodoMiddlewaretodo閉包上設置了一個參數,參數的類型為Middleware中定義了的泛型。

Middleware的作用就是讓某個值通過一連串的中間件,這些中間件所做的事情各不相同,它們可能會對值進行加工,或者完成一些副作用(打Log、數據庫操作、網絡操作等等)。Monoidappend操作將每個中間件組合在一起,形成一個統一的入口,最終我們只需將值傳入這個入口即可。

接下來就是一個簡單使用到Middleware的例子,假設我們現在需要做一個對富文本NSAttributedString進行裝飾的解析器,在里面我們可以根據需要來為富文本提供特定的裝飾(修改字體、前景或背景顏色等),我們可以這樣定義:

// MARK: - Parser
typealias ParserItem = Middleware<NSMutableAttributedString>

func font(size: CGFloat) -> ParserItem {
    return ParserItem { str in
        str.addAttributes([.font: UIFont.systemFont(ofSize: size)], range: .init(location: 0, length: str.length))
    }
}

func backgroundColor(_ color: UIColor) -> ParserItem {
    return ParserItem { str in
        str.addAttributes([.backgroundColor: color], range: .init(location: 0, length: str.length))
    }
}

func foregroundColor(_ color: UIColor) -> ParserItem {
    return ParserItem { str in
        str.addAttributes([.foregroundColor: color], range: .init(location: 0, length: str.length))
    }
}

func standard(withHighlighted: Bool = false) -> ParserItem {
    return font(size: 16) <> foregroundColor(.black) <> backgroundColor(.yellow).when(withHighlighted)
}

func title() -> ParserItem {
    return font(size: 20) <> foregroundColor(.red)
}

extension NSAttributedString {
    func parse(with item: ParserItem) -> NSAttributedString {
        let mutableStr = mutableCopy() as! NSMutableAttributedString
        item.doIt(mutableStr)
        return mutableStr.copy() as! NSAttributedString
    }
}

func parse() {
    let titleStr = NSAttributedString(string: "Monoid").parse(with: title())
    let text = NSAttributedString(string: "I love Monoid!").parse(with: standard(withHighlighted: true))
}

如上代碼,我們首先定義了三個最基本的中間件,分別可用來為NSAttributedString裝飾字體、背景顏色和前景顏色。standardtitle則將基本的中間件進行組合,這兩個組合體用于特定的情境下(為作為標題和作為正文的富文本裝飾),最終文字的解析則通過調用指定中間件來完成。

通過以上的例子我們可以認識到:TodoMiddleware都是一種對行為的抽象,它們之間的區別在于Todo在行為的處理中并不接收外界的數據,而Middleware可從外界獲取某種對行為的輸入。

Order

試想一下我們平時的開發中會經常遇到以下這種問題:

if 滿足條件1 {
    執行優先級最高的操作...
} else if 滿足條件2 {
    執行優先級第二的操作
} else if 滿足條件3 {
    執行優先級第三的操作
} else if 滿足條件4 {
    執行優先級第四的操作
} else if ...

這里可能存在一個問題,那就是優先級的情況。假設某一天程序要求修改將某個分支操作的優先級,如將優先級第三的操作提升到最高,那此時我們不得不改動大部分的代碼來完成這個要求:比方說將兩個或多個if分支代碼的位置互換,這樣改起來那就很蛋疼了。

Order就是用于解決這種與條件判斷相關的優先級問題:

// MARK: - Order
struct Order {
    private let _todo: () -> Bool
    init(_ todo: @escaping () -> Bool) {
        _todo = todo
    }
    
    static func when(_ flag: Bool, todo: @escaping () -> ()) -> Order {
        return .init {
            flag ? todo() : ()
            return flag
        }
    }
    
    @discardableResult
    func doIt() -> Bool {
        return _todo()
    }
}

extension Order: Monoid {
    static func <> (lhs: Order, rhs: Order) -> Order {
        return .init {
            lhs.doIt() || rhs.doIt()
        }
    }

    // Just return false
    static var empty: Order { return .init { false } }
}

在構建Order的時候,我們需要傳入一個閉包,在閉包中我們將處理相關的邏輯,并返回一個布爾值,若此布爾值為true,則代表此Order的工作已經完成,那么之后優先級比它低的Order將不做任何事情,若返回false,代表在這個Order里面我們并沒有做好某個操作(或者說某個操作不符合執行的要求),那么接下來優先級比它低的Order將會嘗試去執行自身的操作,然后按照這個邏輯一直下去。

Order的優先級是通過它們排列的順序決定的,比方說let result = orderA <> orderB <> orderC,那么優先級就是orderA > orderB > orderC,因為我們在定義append的時候使用到了短路運算符||

靜態方法when能夠更加簡便地通過一個布爾值和一個無返回值閉包來構建Order,日常開發可自行選擇使用Order本身的構造函數還是when方法。

func test(shouldSayHello: Bool, shouldLikeSwift: Bool, shouldLikeRust: Bool) {
    let sayHello = Order.when(shouldSayHello) {
        print("Hello, I'm Tangent!")
    }
    
    let likeSwift = Order.when(shouldLikeSwift) {
        print("I like Swift.")
    }
    
    let likeRust = Order.when(shouldLikeRust) {
        print("And also Rust.")
    }
    
    let todo = sayHello <> likeSwift <> likeRust
    todo.doIt()
}

如上面例子中,三個Order的操作要么全部都不會執行,要么就只有一個被執行,這取決于when方法傳入的布爾值,執行的優先級按照append運算的先后順序。

Array

文章已在之前為Array實現了Monoid,那么Array在日常的開發中如何可以利用Monoid的特性呢,我們來看下面的這個代碼:

class ViewController: UIViewController {
    func setupNavigationItem(showAddBtn: Bool, showDoneBtn: Bool, showEditBtn: Bool) {
        var items: [UIBarButtonItem] = []
        if showAddBtn {
            items.append(UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add)))
        }
        if showDoneBtn {
            items.append(UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)))
        }
        if showEditBtn {
            items.append(UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(edit)))
        }
        navigationItem.rightBarButtonItems = items
    }
    
    @objc func add() { }
    @objc func done() { }
    @objc func edit() { }
}

就像在之前講Todo那樣,這樣的代碼寫法的確不優雅,為了給ViewController設置rightBarButtonItems,我們首先得聲明一個數組變量,然后再根據每個條件去給數組添加元素。這樣的代碼是沒有美感的!

我們通過使用ArrayMonoid特性來重構一下上面的代碼:

class ViewController: UIViewController {
    func setupNavigationItem(showAddBtn: Bool, showDoneBtn: Bool, showEditBtn: Bool) {
        let items = [UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add))].when(showAddBtn)
            <> [UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done))].when(showDoneBtn)
            <> [UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(edit))].when(showEditBtn)
        navigationItem.rightBarButtonItems = items
    }
    
    @objc func add() { }
    @objc func done() { }
    @objc func edit() { }
}

這下子就優雅多了~

Writer Monad

Writer Monad是一個基于MonoidMonad(單子),旨在執行操作的過程中去順帶記錄特定的信息,如Log或者歷史記錄。若你不了解Monad沒有關系,這里不會過多提及與它的相關,在閱讀代碼時你只需要搞清楚其中的實現原理即可。

// MARK: - Writer
struct Writer<T, W: Monoid> {
    let value: T
    let record: W
}

// Monad
extension Writer {
    static func `return`(_ value: T) -> Writer {
        return Writer(value: value, record: W.empty)
    }
    
    func bind<O>(_ tran: (T) -> Writer<O, W>) -> Writer<O, W> {
        let newOne = tran(value)
        return Writer<O, W>(value: newOne.value, record: record <> newOne.record)
    }
    
    func map<O>(_ tran: (T) -> O) -> Writer<O, W> {
        return bind { Writer<O, W>.return(tran($0)) }
    }
}

// Use it
typealias LogWriter<T> = Writer<T, String>
typealias Operation<T> = (T) -> LogWriter<T>

func add(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 + num, record: "\($0)加\(num), ") }
}
func subtract(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 - num, record: "\($0)減\(num), ") }
}
func multiply(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 * num, record: "\($0)乘\(num), ") }
}
func divide(_ num: Int) -> Operation<Int> {
    return { Writer(value: $0 / num, record: "\($0)除\(num), ") }
}

func test() {
    let original = LogWriter.return(2)
    let result = original.bind(multiply(3)).bind(add(2)).bind(divide(4)).bind(subtract(1))
    // 1
    print(result.value)
    // 2乘3, 6加2, 8除4, 2減1,
    print(result.record)
}

Writer為結構體,其中包含著兩個數據,一個是參與運算的值,類型為泛型T,一個是運算時所記錄的信息,類型為泛型W,并且需要實現Monoid

return靜態方法能夠創建一個新的Writer,它需要傳入一個值,這個值將直接保存在Writer中。得益于Monoid單位元的特性,return在構建Writer的過程中直接將empty設置為Writer所記錄的信息。

bind方法所要做的就是通過傳入一個能將運算值轉化成Writer的閉包來對原始Writer進行轉化,在轉化的過程中bind將記錄信息進行append,這樣就能幫助我們自動進行信息記錄。

map方法通過傳入一個運算值的映射閉包,將Writer內部的運算值進行轉換。

其中,map運算來源于函數式編程概念Functorreturnbind則來源于Monad。大家如果對此有興趣的可以查閱相關的內容,或者閱讀我在之前寫的有關于這些概念的文章。

利用Writer Monad,我們就可以專心于編寫代碼的業務邏輯,而不必花時間在一些信息的記錄上,Writer會自動幫你去記錄。

這篇文章沒有提及到的Monoid還有很多,如AnyAllOrdering ...,大家可以通過查閱相關文檔來進行。

對于Monoid來說,重要的不是在于去了解它相關的實現例子,而是要深刻地理解它的抽象概念,這樣我們才能說認識Monoid,才能舉一反三,去定義屬于自己的Monoid實例。

事實上Monoid的概念并不復雜,然而函數式編程的哲學就是這樣,希望通過一個個細微的抽象,將它們組合在一起,最終成就了一個更為龐大的抽象,構建出了一個極其優雅的系統。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,443評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,530評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,407評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,981評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,759評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,204評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,263評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,415評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,955評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,650評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,892評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,675評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容

  • 前言 近期又開始折騰起Haskell,掉進這個深坑恐怕很難再爬上來了。在不斷深入了解Haskell的各種概念以及使...
    Tangentw閱讀 2,155評論 0 9
  • 背景 所有一切的開始都是因為這句話:一個單子(Monad)說白了不過就是自函子范疇上的一個幺半群而已,有什么難以理...
    福克斯記閱讀 13,924評論 6 65
  • 真是不知道為啥,每次回家都有讓我寫作的靈感,或者總能遇到讓人心有感觸的事情吧。 今天為了見一個人,我在北京的...
    Zlatan_閱讀 320評論 0 0
  • 不還是那句話嗎?愛ta就“如ta所是,非我所愿” 孩子哭了,立馬喝止原因是怕吵醒別人了,會打你。 如果去關心她的想...
    生活如小鳥滿枝丫閱讀 145評論 0 0
  • 夕陽 帶著些許失落 隱于大山身后 行人舒口氣 舒出了日間夾雜的余熱 星辰 帶著些許興奮 掛于東山之上 路人抬眼瞅 ...
    震血封侯閱讀 331評論 0 6