在你身邊你左右 --函數式編程別煩惱

下一篇《函數式編程之Promise的奇幻漂流》 javascript

曾經的你是否是總在工做和學習過程當中聽到函數式編程(FP)。但學到函子的時候老是一頭霧水。本文是我在函數式編程學習過程當中,總結的筆記,也分享給想學函數式編程的同窗。html

在學以前,你先問本身幾個問題,或者看成一場面試,看看下面的這些問題,你該怎麼回答?前端

  • 你能說出對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))
 
複製代碼

如今就讓咱們帶着問題去學習吧。文章的最後,咱們再次總結這些問題的答案。java

1.1 函數式編程(FP)思想

面對對象(OOP)能夠理解爲是對數據的抽象,好比把一我的抽象成一個Object,關注的是數據。 函數式編程是一種過程抽象的思惟,就是對當前的動做去進行抽象,關注的是動做。react

舉個例子:若是一個數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,那麼能夠合併的函數須要符合什麼條件呢?下面就講到了純函數的這個概念。es6

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")
複製代碼

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

首先不管輸入什麼,輸出都是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 的庫。進一步深刻每一個細節去了解函數式編程。 學習更多的技巧。

最後本文是我學習函數式編程的筆記,寫的時候常常自言自語,偶爾還安慰本身。若是有錯的地方,歡迎你們批評指正。

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

相關文章
相關標籤/搜索