在你身邊你左右 --函數語言程式設計別煩惱

17點發表於2018-06-17

下一篇《函數語言程式設計之Promise的奇幻漂流》

曾經的你是不是總在工作和學習過程中聽到函數語言程式設計(FP)。但學到函子的時候總是一頭霧水。本文是我在函數語言程式設計學習過程中,總結的筆記,也分享給想學函數語言程式設計的同學。

在學之前,你先問自己幾個問題,或者當作一場面試,看看下面的這些問題,你該怎麼回答?

  • 你能說出對javaScript工程師比較重要的兩種程式設計正規化嗎?
  • 什麼是函數語言程式設計?
  • 函數語言程式設計和麵向物件各有什麼優點和不足呢?
  • 你瞭解閉包嗎?你經常在那些地方使用?閉包和柯里化有什麼關係?
  • 如果我們想封裝一個像underscorede的防抖的函式該怎麼實現?
  • 你怎麼理解函子的概念?Monad函子又有什麼作用?
  • 下面這段程式碼的執行結果是什麼?
var Container = function(x) { this.__value = x;  } 
Container.of = x => new Container(x);  

Container.prototype.map = function(f){  
      console.log(f)
     return Container.of(f(this.__value)) 
}  

Container.of(3).map(x=>x+1).map(x => 'Result is ' + x);
console.log(Container.of(3).map(x=>x+1).map(x => 'Result is ' + x))
 
複製程式碼

現在就讓我們帶著問題去學習吧。文章的最後,我們再次總結這些問題的答案。

1.1 函數語言程式設計(FP)思想

面對物件(OOP)可以理解為是對資料的抽象,比如把一個人抽象成一個Object,關注的是資料。 函數語言程式設計是一種過程抽象的思維,就是對當前的動作去進行抽象,關注的是動作。

舉個例子:如果一個數a=1 ,我們希望執行+3(f函式),然後再*5(g函式),最後得到結果result是20

資料抽象,我們關注的是這個資料:a=1 經過f處理得到  a=4 , 再經過g處理得到 a = 20

過程抽象,我們關注的是過程:a要執行兩個f,g兩操作,先將fg合併成一個K操作,然後a直接執行K,得到 a=20
複製程式碼

問題:f和g合併成了K,那麼可以合併的函式需要符合什麼條件呢?下面就講到了純函式的這個概念。

1.2 純函式

定義:一個函式如果輸入引數確定,輸出結果是唯一確定的,那麼他就是純函式。
特點:無狀態,無副作用,無關時序,冪等(無論呼叫多少次,結果相同)

下面哪些是純函式 ?

let arr = [1,2,3];                                            
arr.slice(0,3);                                               //是純函式
arr.splice(0,3);                                              //不是純函式,對外有影響

function add(x,y){                                           // 是純函式   
   return x + y                                              // 無狀態,無副作用,無關時序,冪等
}                                                            // 輸入引數確定,輸出結果是唯一確定

let count = 0;                                               //不是純函式 
function addCount(){                                         //輸出不確定
    count++                                                  // 有副作用
}

function random(min,max){                                    // 不是純函式     
    return Math.floor(Math.radom() * ( max - min)) + min     // 輸出不確定
}                                                            // 但注意它沒有副作用


function setColor(el,color){                                  //不是純函式 
    el.style.color =  color ;                                 //直接操作了DOM,對外有副作用
}                                                             
複製程式碼

是不是很簡單,接下來我們加一個需求?
如果最後一個函式,你希望批量去操作一組li並且還有許多這樣的需求要改,寫一個公共函式?

function change (fn , els , color){
    Array.from(els).map((item)=>(fn(item,color)))
}
change(setColor,oLi,"blue")
複製程式碼

那麼問題來了這個函式是純函式嗎?

首先無論輸入什麼,輸出都是undefined,接下來我們分析一下對外面有沒有影響,我們發現,在函式裡並沒有直接的影響,但是呼叫的setColor對外面產生了影響。那麼change到底算不算純函式呢?

答案是當然不算,這裡我們強調一點,純函式的依賴必須是無影響的,也就是說,在內部引用的函式也不能對外造成影響。

問題:那麼我們有沒有什麼辦法,把這個函式提純呢?

1.3 柯里化(curry)

定義:只傳遞給函式一部分引數來呼叫它,讓它返回一個函式去處理剩下的引數。

javascript 
function add(x, y) {
     return x + y;
}
add(1, 2)
 
******* 柯里化之後 *************
  
function addX(y) {
   return function (x) { 
    return x + y;
   }; 
}
var newAdd =  addX(2) 
 newAdd (1)  
複製程式碼

現在我們回過頭來看上一節的問題?
如果我們不讓setColor在change函式裡去執行,那麼change不就是純函式了嗎?

javascript    
function change (fn , els , color){
    Array.from(els).map((item)=>(fn(item,color)))
}
change(setColor,oLi,"blue")

****** 柯里化之後 *************

function change(fn){
    return function(els,color){
        Array.from(els).map((item)=>(fn(item,color)))
    }
}
var newSetColor = change(setColor);
newSetColor(oLi,"blue")
複製程式碼
  • 我們先分析柯里化(curry)過程。在之前change函式中fn , els , color三個引數,每次呼叫的時候我們都希望引數fn值是 setColor,因為我們想把不同的顏色給到不同的DOM上。我們的最外層的引數選擇了fn,這樣返回的函式就不用再輸入fn值啦。
  • 接下來我們分析提純的這個過程,改寫後無論fn輸入是什麼,都return出唯一確定的函式,並且在change這個函式中,只執行了return這個語句,setColor函式並未在change上執行,所以change對外也不產生影響。顯然change這時候就是一個純函式。
  • 最後如果我們拋棄柯里化的概念,這裡就是一個最典型的閉包用法而已。而change函式的意義就是我們可以通過它把一類setColor函式批量去改成像newSetColor這樣符合新需求的函式。

上面那個例子是直接重寫了change函式,能不能直接在原來change的基礎上通過一個函式改成 newSetColor呢?

javascript    
function change (fn , els , color){
    Array.from(els).map((item)=>(fn(item,color)))
}
change(setColor,oLi,"blue")

//******* 通過一個curry函式*************

var changeCurry = curry(change);
var newSetColor = changeCurry(setColor);
newSetColor(oLi,"blue")
複製程式碼

哇!真的有這種函式嗎?當然作為幫助函式(helper function),lodash 或 ramda都有啊。我們在深入的系列的課程中會動(chao)手(xi)寫一個。

問題:處理上一個問題時,我們將一個函式作為引數傳到另一個函式中去處理,這好像在函數語言程式設計中很常見,他們有什麼規律嗎?

1.4 高階函式

定義:函式當引數,把傳入的函式做一個封裝,然後返回這個封裝函式,達到更高程度的抽象。

很顯然上一節用傳入fn的change函式就是一個高階函式,顯然它是一個純函式,對外沒有副作用。可能這麼講並不能讓你真正去理解高階函式,那麼我就舉幾個例子!

1.4.1 等價函式

定義 :呼叫函式本身的地方都可以其等價函式;

javascript    
function __equal__(fn){
        return function(...args){
            return fn.apply(this,args);
        }
    }
//第一種
function add(x,y){
    return x + y
}
var addnew1 = __equal__(add);
console.log(add(1,2));
console.log(addnew1(1,2));

//第二種
let obj = {
      x : 1,
      y : 2,
      add : function (){
        console.log(this)
        return this.x + this.y  
      }
   }
   
var addnew2 = __equal__(obj.add);

console.log( obj.add() ) ;           //3
console.log( addnew2.call(obj));      //3

複製程式碼

第一種不考慮this

  • equal(add):讓等價(equal)函式傳入原始函式形成閉包,返回一個新的函式addnew1
  • addnew1(1,2):addnew1中傳入引數,在fn中呼叫,fn變數指向原始函式

第二種考慮this

  • addnew2.call(obj): 讓__equal__函式返回的addnew2函式在obj的環境中執行,也就是fn.apply(this,args);中的父級函式中this,指向obj
  • fn.apply(this,args)中,this是一個變數,繼承父級, 父級指向obj,所以在obj的環境中呼叫fn
  • fn是閉包形成指向obj.add

好了,看懂程式碼後,我們發現,這好像和直接把函式賦值給一個變數沒啥區別,那麼等價函式有什麼好處呢?

等價函式的攔截和監控:

javascript    
function __watch__(fn){
        //偷偷乾點啥
         return function(...args){
            //偷偷乾點啥
            let ret = fn.apply(this,args);
            //偷偷乾點啥
            return ret
         }
}
複製程式碼

我們知道,上面本質就是等價函式,fn執行結果沒有任務問題。但是可以在執行前後,偷偷做點事情,比如consle.log("我執行啦")。

問題:等價函式可以用於攔截和監控,那有什麼具體的例子嗎?

1.4.2 節流(throtle)函式

前端開發中會遇到一些頻繁的事件觸發,為了解決這個問題,一般有兩種解決方案:

  • throttle 節流
  • debounce 防抖
javascript 

function throttle(fn,wait){
     var timer;
     return function(...args){
        if(!timer){
            timer = setTimeout(()=>timer=null , wait);
            console.log(timer)
            return fn.apply(this,args)
        }
     }
}

const fn  = function(){ console.log("btn clicked")}
const btn = document.getElementById('btn');
btn.onclick = throttle(fn , 5000);

複製程式碼

分析程式碼

  • 首先我們定義了一個timer
  • 當timer不存在的時候,執行if判斷裡函式
  • setTimeout給timer 賦一個id值,fn也執行
  • 如果繼續點選,timer存在,if判斷裡函式不執行
  • 當時間到時,setTimeout的回撥函式清空timer,此時再去執行if判斷裡函式

所以,我們通過對等價函式監控和攔截很好的實現了節流(throtle)函式。而對函式fn執行的結果絲毫沒有影響。這裡給大家留一個作業,既然我們實現了節流函式,那麼你能不能根據同樣的原理寫出防抖函式呢?

問題:哦,像這樣節流函式,在我平時的專案中直接寫就好了,你封裝成這樣一個函式似乎還麻煩了呢?

1.5 命令式與宣告式

在平時,如果我們不借助方法函式去實現節流函式,我們可能會直接這麼去實現節流函式。

  var timer;
  btn.onclick = function(){ 
   if(!timer){
      timer = setTimeout(()=>timer=null , 5000);
      console.log("btn clicked")
   }
}
複製程式碼

那麼與之前的高階函式有什麼區別呢?

很顯然,在下面的這例子中,我們每次在需要做節流的時候,我們每次都需要這樣重新寫一次程式碼。告訴 程式如何執行。而上面的高階函式的例子,我們定義好了一個功能函式之後,我們只需要告訴程式,你要做 什麼就可以啦。

  • 命令式 : 上面的例子就是命令式
  • 宣告式 : 高階函式的例子就是宣告式

那下面大家看看,如果遍歷一個陣列,列印出每個陣列中的元素,如何用兩種方法實現呢?

  //命令式
  var array = [1,2,3];
  for (i=0; i<array.length;i++){
    console.log(array[i])
  }
  
  //宣告式
  array.forEach((i) => console.log(i))
複製程式碼

看到forEach是不是很熟悉,原來我們早就在大量使用函數語言程式設計啦。

這裡我們可以先停下來從頭回顧一下,函數語言程式設計。

  • 函數語言程式設計,更關注的是動作,比如我們定義的節流函式,就是把節流的這個動作抽象出來。
  • 所以這樣的函式必須要輸入輸出確定且對外界沒有,我們把這樣的函式叫純函式
  • 對於不純的函式提純的過程中,用到了柯里化的方法。
  • 我們柯里化過程中,我們傳進去的引數恰恰是一個函式,返回的也是一個函式,這就叫高階函式。
  • 高階函式往往能抽象寫出像節流這樣的功能函式。
  • 宣告式就是在使用這些功能函式

問題:現在我們對函式程式設計有了初步的瞭解,但還並沒有感受到它的厲害,還記得我們之前講到的純函式可以合併嗎?下一節,我們就去實現它

1.6 組合(compose)

function double(x) {
  return x * 2
}
function add5(x) {
  return x + 5
}
double(add5(1))
複製程式碼

上面的程式碼我們實現的是完成了兩個動作,不過我們覺得這樣寫double(add5(x)),不是很舒服。 換一個角度思考,我們是不是可以把函式合併在一起。 我們定義了一個compose函式

var compose = function(f, g) {
    return function(x) {
        return f(g(x));
    };
};
複製程式碼

有了compose這個函式,顯然我們可以把double和add5合併到一起

var numDeal =  compose(double,add5)
numDeal(1)
複製程式碼
  • 首先我們知道compose合併的double,add5是從右往左執行的
  • 所以1先執行了加5,在完成了乘2

那麼這時候就有幾個問題,

  • 這隻使用與一個引數,如果是多個引數怎麼辦?有的同學已經想到了用柯里化
  • 還有這只是兩個函式,如果是多個函式怎麼辦。知道reduce用法的同學,可能已經有了思路。
  • compose是從從右往左執行,我想左往右行不行?當然,他還有個專門的名字叫管道(pipe)函式

這三道題我們留作思考題。我們在深入的專題裡會去實現的哈。

問題:現在我們想完成一些功能都需要去合併函式,而且合併的函式還會有一定順序,我們能不能像JQ的鏈式呼叫那樣去處理資料呢。

1.7 函子(Functor)

講到函子,我們首先回到我們的問題上來。之前我們執行函式通常是下面這樣。

function double(x) {
  return x * 2
}
function add5(x) {
  return x + 5
}

double(add5(1))
//或者
var a = add5(5)
double(a)
複製程式碼

那現在我們想以資料為核心,一個動作一個動作去執行。

 (5).add5().double()

複製程式碼

顯然,如果能這樣執行函式的話,就舒服多啦。那麼我們知道,這樣的去呼叫要滿足

  • (5)必須是一個引用型別,因為需要掛載方法。
  • 引用型別上要有可以呼叫的方法

所以我們試著去給他建立一個引用型別

class Num{
       constructor (value) {
          this.value = value ;
       }      
       add5(){
           return this.value + 5
       }
       double(){
           return this.value * 2
       }
    }

var num = new Num(5);
num.add5()
複製程式碼

我們發現這個時候有一個問題,就是我們經過呼叫後,返回的就是一個值了,我們沒有辦法進行下一步處理。所以我們需要返回一個物件。

class Num{
       constructor (value) {
          this.value = value ;
       }      
       add5 () {
           return  new Num( this.value + 5)
       }
       double () {
           return  new Num( this.value * 2)
       }
    }
var num = new Num(2);
num.add5 ().double ()
複製程式碼
  • 我們通過new Num ,建立了一個num 一樣型別的例項
  • 把處理的值,作為引數傳了進去從而改變了this.value的值
  • 我們把這個物件返了回去,可以繼續呼叫方法去處理函式

我們發現,new Num( this.value + 5),中對this.value的處理,完全可以通過傳進去一個函式去處理

並且在真實情況中,我們也不可能為每個例項都建立這樣有不同方法的建構函式,它們需要一個統一的方法。

class Num{
       constructor (value) {
          this.value = value ;
       }      
       map (fn) {
         return new Num(fn(this.value))
       }
    }
var num = new Num(2);
num.map(add5).map(double)
複製程式碼

我們建立了一個map的方法,把處理的函式fn傳了進去。這樣我們就完美的實現啦,我們設想的功能啦。

最後我們整理一下,這個函式。

class Functor{
       constructor (value) {
          this.value = value ;
       }      
       map (fn) {
         return Functor.of(fn(this.value))
       }
    }
Functor.of = function (val) {
     return new Functor(val);
}

Functor.of(5).map(add5).map(double)
複製程式碼
  • 我們把原來的建構函式Num的名字改成了Functor
  • 我們給new Functor(val);封住了一個方法Functor.of

現在Functor.of(5).map(add5).map(double)去呼叫函式。有沒有覺得很爽。

哈哈,更爽的是,你已經在不知不覺間把函子的概念學完啦。上面這個例子總的Functor就是函子。現在我們來總結一下,它有那些特點吧。

  • Functor是一個容器,它包含了值,就是this.value.(想一想你最開始的new Num(5))
  • Functor具有map方法。該方法將容器裡面的每一個值,對映到另一個容器。(想一想你在裡面是不是new Num(fn(this.value))
  • 函數語言程式設計裡面的運算,都是通過函子完成,即運算不直接針對值,而是針對這個值的容器----函子。(想一想你是不是沒直接去操作值)
  • 函子本身具有對外介面(map方法),各種函式就是運算子,通過介面接入容器,引發容器裡面的值的變形。(說的就是你傳進去那個函式把this.value給處理啦)
  • 函數語言程式設計一般約定,函子有一個of方法,用來生成新的容器。(就是最後我們們整理了一下函式嘛)

嗯,這下明白什麼是函子了吧。在初學函式程式設計時,一定不要太過於糾結概念。看到好多,教程上在講 函子時全然不提JavaScript語法。用生硬的數學概念去解釋。

我個人覺得書讀百遍,其義自見。對於程式設計正規化的概念理解也是一樣的,你先知道它是什麼。怎麼用。 多寫多練,自然就理解其中的含義啦。總抱著一堆概念看,是很難看懂的。

以上,函子(Functor)的解釋過程,個人理解。也歡迎大家指正。

問題:我們實現了一個最通用的函子,現在別問問題,我們趁熱打鐵,再學一個函子

1.7.1 Maybe 函子

我們知道,在做字串處理的時候,如果一個字串是null, 那麼對它進行toUpperCase(); 就會報錯。

Functor.of(null).map(function (s) {
  return s.toUpperCase();
});
複製程式碼

那麼我們在Functor函子上去進行呼叫,同樣也會報錯。

那麼我們有沒有什麼辦法在函子裡把空值過濾掉呢。

class Maybe{
       constructor (value) {
          this.value = value ;
       }      
       map (fn) {
          return this.value ? Maybe.of(fn(this.value)) : Maybe.of(null);
       }
    }
Maybe.of = function (val) {
     return new Maybe(val);
}

var a = Maybe.of(null).map(function (s) {
  return s.toUpperCase();
});
複製程式碼

我們看到只需要把在中設定一個空值過濾,就可以完成這樣一個Maybe函子。

所以各種不同型別的函子,會完成不同的功能。學到這,我們發現,每個函子並沒有直接去操作需要處理的資料,也沒有參與到處理資料的函式中來。

而是在這中間做了一些攔截和過濾。這和我們的高階函式是不是有點像呢。所以你現在對函數語言程式設計是不是有了更深的瞭解啦。

現在我們就用函數語言程式設計做一個小練習: 我們有一個字串‘li’,我們希望處理成大寫的字串,然後載入到id為text的div上

   var str = 'li';
   Maybe.of(str).map(toUpperCase).map(html('text'))
複製程式碼

如果在有編寫好的Maybe函子和兩個功能函式的時候,我們只需要一行程式碼就可以搞定啦

那麼下面看看,我們的依賴函式吧。

  let $$ = id => Maybe.of(document.getElementById(id));
  class Maybe{
     constructor(value){
          this.__value = value;   
     }
     map(fn){
      return this.__value ? Maybe.of(fn(this.__value)) : Maybe.of(null);
     }
     static of(value){
        return new Maybe(value);
     }
  }
  let toUpperCase = str => str.toUpperCase();
  let html = id => html => {
     $$(id).map(dom => {
        dom.innerHTML = html;
     });
  };
  
複製程式碼

我們來分析一下程式碼

  • 因為Maybe.of(document.getElementById(id)我們會經常用到,所以用雙$封裝了一下
  • 然後是一個很熟悉的Maybe函子,這裡of用的Class的靜態方法
  • toUpperCase是一個普通純函式(es6如果不是很好的同學,可以用babel )編譯成es5
  • html是一個高階函式,我們先傳入目標dom的id然後會返回一個函式將,字串掛在到目標dom上
var html = function(id) {
   return function (html) {
      $$(id).map(function (dom) {
         dom.innerHTML = html;
      });
   };
};
複製程式碼

大家再來想一個問題 Maybe.of(str).map(toUpperCase).map(html('text'))最後的值是什麼呢?

我們發現最後沒有處理的函式沒有返回值,所以最後結果應該是 Maybe {__value: undefined}; 這裡面給大家留一個問題,我們把字串列印在div上之後想繼續操作字串該怎麼辦呢?

問題:在理解了函子這個概念之後,我們來學習本文最後一節內容。有沒有很開心

1.8 Monad函子

Monad函子也是一個函子,其實很原理簡單,只不過它的功能比較重要。那我們來看看它與其它的 有什麼不同吧。

我們先來看這樣一個例子,手敲在控制檯列印一下。

var a = Maybe.of( Maybe.of( Maybe.of('str') ) ) 
console.log(a);
console.log(a.map(fn));
console.log(a.map(fn).map(fn));

function fn(e){ return e.value }
 
複製程式碼
  • 我們有時候會遇到一種情況,需要處理的資料是 Maybe {value: Maybe}
  • 顯然我們需要一層一層的解開。
  • 這樣很麻煩,那麼我們有沒有什麼辦法得到裡面的值呢
class Maybe{
       constructor (value) {
          this.value = value ;
       }      
       map (fn) {
          return this.value ? Maybe.of(fn(this.value)) : Maybe.of(null);
       }
       join ( ) {
          return this.value;
       }
    }
Maybe.of = function (val) {
     return new Maybe(val);
}
 
複製程式碼

我們想取到裡面的值,就把它用join方法返回來就好了啊。所以我給它加了一個join方法

var  a = Maybe.of( Maybe.of('str') ) 
console.log(a.join().map(toUpperCase)) 
複製程式碼

所以現在我們可以通過,join的方法一層一層得到裡面的資料,並把它處理成大寫

現在你肯定會好奇為什麼會產生Maybe.of( Maybe.of('str')) 結構呢?

還記得html那個函式嗎?我們之前留了一個問題,字串列印在div上之後想繼續操作字串該怎麼辦呢?

很顯然我們需要讓這個函式有返回值。

 let html = id => html => {
    return  $$(id).map(dom => {
        dom.innerHTML = html;
        return html
     });
  };

複製程式碼

分析一下程式碼。

  • 如果只在裡面加 return html,外面函式並沒有返回值
  • 如果只在外面加return,則取不到html
  • 所以只能裡面外面都加
  • 這就出現了 Maybe.of( Maybe.of('LI') )

那麼這時候我們想,既然我們在執行的時候就知道,它會有影響,那我能不能在執行的時候,就把這個應該 給消除呢。

class Maybe{
       constructor (value) {
          this.value = value ;
       }      
       map (fn) {
          return this.value ? Maybe.of(fn(this.value)) : Maybe.of(null);
       }
       join ( ){
          return this.value;
       }
       chain(fn) {
          return this.map(fn).join();
       }
    }

複製程式碼

我們寫了一個chain函式。首先它呼叫了一下map方法,執行結束後,在去掉一層巢狀的函子

所以在執行的時候,我們就可以這樣去寫。

 Maybe.of(str).map(toUpperCase).chain(html('text'))
複製程式碼

這樣返回的函式就是隻有一層巢狀的函子啦。

學到這裡我們已經把全部的函數語言程式設計所涉及到概念都學習完啦。現在要是面試官拿這樣一道題問題,答案是什麼?是不是有點太簡單啦。

var Container = function(x) { this.__value = x;  } 
Container.of = x => new Container(x);  

Container.prototype.map = function(f){  
      console.log(f)
     return Container.of(f(this.__value)) 
}  

Container.of(3).map(x=>x+1).map(x => 'Result is ' + x);
console.log(Container.of(3).map(x=>x+1).map(x => 'Result is ' + x))
 
複製程式碼

但你會發現我們並沒有具體糾結每一個概念上,而是更多的體現在可實現的程式碼上,而這些程式碼你也並不陌生。

哈哈,那你可能會問,我是不是學了假的函數語言程式設計,並沒有。因為我覺得函數語言程式設計也是程式設計,最終都是要回歸到日常專案的實踐中。而應對不同難度的專案,所運用的知識當然也是不一樣的,就好比造船,小船有小船的造法,郵輪有油輪的造法,航母有航母的造法。你沒有 必要把全部的造船知識點,逐一學完才開始動手。日常況且在工作中,你可能也並有真正的機會去造航母(比如寫框架)。與其把大量的時間都花在理解那些概念上,不如先動手造一艘小船踏實。所以本文中大量淡化了不需要去立即學習的概念。

現在,當你置身在函數語言程式設計的那片海中,看見泛起的一葉葉扁舟,是不是不再陌生了呢?

是不是在海角和天邊,還劃出一道美麗的曲線?

那麼接下來我們會動手實踐一個Underscore.js 的庫。進一步深入每個細節去了解函數語言程式設計。 學習更多的技巧。

最後本文是我學習函數語言程式設計的筆記,寫的時候經常自言自語,偶爾還安慰自己。如果有錯的地方,歡迎大家批評指正。

文章最後總結的上面的答案是有的,不過現在還在我心中,等我有時間在寫啊 啊 啊。。。。

相關文章