ES6 尾調(diào)用和尾遞歸

尾調(diào)用(Tail Call)是函數(shù)式編程的一個重要概念父阻,本身非常簡單愈涩,一句話就能說清楚望抽,就是指某個函數(shù)的最后一步是調(diào)用另一個函數(shù)。

遞歸函數(shù)

假設(shè)我們需要計算階乘 factorial「n! = 1 * 2 * 3 * ... * n」履婉,用函數(shù)表示

image.png

fact(n) 用遞歸方式寫:

def fact(n: int) -> int:
    if n == 1:
        return 1
    return n * fact(n - 1)

計算 fact(5)煤篙,過程如下:

> fact(5)
> 5 * fact(4)
> 5 * (4 * fact(3))
> 5 * (4 * (3 * fact(2)))
> 5 * (4 * (3 * (2 * fact(1))))
> 5 * (4 * (3 * (2 * 1)))
> 5 * (4 * (3 * 2))
> 5 * (4 * 6)
> 5 * 24
> 120

在每次遞歸調(diào)用的時候,都會產(chǎn)生一個臨時變量毁腿,導(dǎo)致進(jìn)程內(nèi)存占用量變大辑奈。這樣執(zhí)行遞歸層數(shù)比較深的代碼時,除了無謂的內(nèi)存浪費(fèi)已烤,還可能導(dǎo)致著名的堆棧溢出錯誤鸠窗。

在計算機(jī)中,函數(shù)調(diào)用是通過棧 Stack 這種數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)的胯究,每當(dāng)進(jìn)入一個函數(shù)調(diào)用稍计,棧就會加一層棧幀,當(dāng)數(shù)據(jù)返回值時唐片,棧就會減一層棧幀丙猬。由于棧的大小不是無限的,所以遞歸調(diào)用的次數(shù)過多费韭,會導(dǎo)致棧溢出茧球。

>>> fact(1000)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in fact
  ...
  File "<stdin>", line 4, in fact
RuntimeError: maximum recursion depth exceeded in comparison

理論上,所有的遞歸函數(shù)都可以寫成循環(huán)的方式星持,避免對棧溢出抢埋,但循環(huán)的邏輯不如遞歸清晰。改成循環(huán)就是手動維護(hù)一些數(shù)據(jù)結(jié)構(gòu)來緩存數(shù)據(jù)督暂,而不是用系統(tǒng)的調(diào)用棧揪垄。

遞歸函數(shù)不斷調(diào)用自身,這種機(jī)械的重復(fù)行為很適合機(jī)器來執(zhí)行逻翁,但是由于調(diào)用棧大小的限制饥努,我們需要對遞歸做優(yōu)化 - 尾遞歸

什么是尾調(diào)用(Tail Call)八回?

尾調(diào)用(Tail Call)是函數(shù)式編程的一個重要概念酷愧,本身非常簡單,一句話就能說清楚缠诅,就是指某個函數(shù)的最后一步是調(diào)用另一個函數(shù)溶浴。

function f(x){
  return g(x);
}

上面代碼中,函數(shù)f的最后一步是調(diào)用函數(shù)g管引,這就叫尾調(diào)用士败。
以下三種情況,都不屬于尾調(diào)用褥伴。

// 情況一
function f(x){
  let y = g(x);
  return y;
}

// 情況二
function f(x){
  return g(x) + 1;
}

// 情況三
function f(x){
  g(x);
}

上面代碼中谅将,情況一是調(diào)用函數(shù)g之后漾狼,還有賦值操作,所以不屬于尾調(diào)用戏自,即使語義完全一樣邦投。情況二也屬于調(diào)用后還有操作,即使寫在一行內(nèi)擅笔。情況三等同于下面的代碼。

function f(x){
  g(x);
  return undefined;
}
尾調(diào)用不一定出現(xiàn)在函數(shù)尾部屯援,只要是最后一步操作即可猛们。

function f(x) {
  if (x > 0) {
    return m(x)
  }
  return n(x);
}

上面代碼中,函數(shù)m和n都屬于尾調(diào)用狞洋,因?yàn)樗鼈兌际呛瘮?shù)f的最后一步操作弯淘。

嚴(yán)格模式才有效

ES6 的尾調(diào)用優(yōu)化只在嚴(yán)格模式下開啟,正常模式是無效的吉懊。
這是因?yàn)樵谡DJ较侣龋瘮?shù)內(nèi)部有兩個變量,可以跟蹤函數(shù)的調(diào)用棧借嗽。

  • func.arguments:返回調(diào)用時函數(shù)的參數(shù)态鳖。
  • func.caller:返回調(diào)用當(dāng)前函數(shù)的那個函數(shù)。
    尾調(diào)用優(yōu)化發(fā)生時恶导,函數(shù)的調(diào)用棧會改寫浆竭,因此上面兩個變量就會失真。嚴(yán)格模式禁用這兩個變量惨寿,所以尾調(diào)用模式僅在嚴(yán)格模式下生效邦泄。
function restricted() {
  'use strict';
  restricted.caller;    // 報錯
  restricted.arguments; // 報錯
}
restricted();

尾調(diào)用優(yōu)化

在開始進(jìn)一步了解尾調(diào)用優(yōu)化前,我們首先要熟悉兩個概念裂垦,分別是調(diào)用棧(call stack)和調(diào)用幀(call frame)顺囊。

調(diào)用棧(call stack)

  • 調(diào)用棧是一種棧結(jié)構(gòu)的數(shù)據(jù),它是由調(diào)用偵組成的蕉拢。
  • 調(diào)用棧記錄了函數(shù)的執(zhí)行順序和函數(shù)內(nèi)部變量等信息特碳。
  • 程序運(yùn)行到一個函數(shù),它就會將其添加到“調(diào)用椘罅浚”中测萎,當(dāng)從這個函數(shù)返回的時候,就會將這個函數(shù)從調(diào)用棧中刪掉届巩。

調(diào)用幀(call frame)
每個進(jìn)入到調(diào)用棧中的函數(shù)硅瞧,都會分配到一個單獨(dú)的棧空間恕汇,稱為“調(diào)用偵”腕唧。

在調(diào)用棧中每個“調(diào)用偵”都對應(yīng)一個函數(shù)或辖,最上方的調(diào)用幀稱為“當(dāng)前幀”,調(diào)用棧是由所有的調(diào)用偵形成的枣接。


image.png

尾調(diào)用之所以與其他調(diào)用不同颂暇,就在于它的特殊的調(diào)用位置。

如果在函數(shù)A的內(nèi)部調(diào)用函數(shù)B但惶,那么在A的調(diào)用幀上方耳鸯,還會形成一個B的調(diào)用幀。等到B運(yùn)行結(jié)束膀曾,將結(jié)果返回到A县爬,B的調(diào)用幀才會消失。如果函數(shù)B內(nèi)部還調(diào)用函數(shù)C添谊,那就還有一個C的調(diào)用幀财喳,以此類推。所有的調(diào)用幀斩狱,就形成一個“調(diào)用棧(call stack)”耳高。

話不多說,舉個例子

function foo () { console.log(111); }
function bar () { foo(); }
function baz () { bar(); }

baz();

調(diào)用棧情況參考下圖:


image.png

造成這種結(jié)果是因?yàn)槊總€函數(shù)在調(diào)用另一個函數(shù)的時候所踊,并沒有 return 該調(diào)用三妈,所以JS引擎會認(rèn)為你還沒有執(zhí)行完柑司,會保留你的調(diào)用幀。

baz() 里面調(diào)用了 bar() 函數(shù),并沒有 return 該調(diào)用表锻,所以在調(diào)用棧中保持自己的調(diào)用幀坦康,同時 bar() 函數(shù)的調(diào)用幀在調(diào)用棧中生成浑度,同理字支,bar() 函數(shù)又調(diào)用了 foo() 函數(shù),最后執(zhí)行到 foo() 函數(shù)的時候惋增,沒有再調(diào)用其他函數(shù)叠殷,這里沒有顯示聲明 return,所以這里默認(rèn) return undefined诈皿。

foo() 執(zhí)行完了林束,銷毀調(diào)用棧中自己的記錄,依次銷毀 bar() 和 baz() 的調(diào)用幀稽亏,最后完成整個流程壶冒。

如果對上面的例子做如下修改:

function foo () { console.log(111); }
function bar () { return foo(); }
function baz () { return bar(); }

baz();

如果尾調(diào)用優(yōu)化生效,流程圖就會變成這樣:


image.png

我們可以很清楚的看到截歉,尾調(diào)用由于是函數(shù)的最后一步操作胖腾,所以不需要保留外層函數(shù)的調(diào)用記錄,只要直接用內(nèi)層函數(shù)的調(diào)用記錄取代外層函數(shù)的調(diào)用記錄就可以了,調(diào)用棧中始終只保持了一條調(diào)用幀咸作。

這就叫做尾調(diào)用優(yōu)化(Tail call optimization)锨阿,即只保留內(nèi)層函數(shù)的調(diào)用幀。如果所有的函數(shù)都是尾調(diào)用的話记罚,調(diào)用位置墅诡、內(nèi)部變量等信息都不會再用到了,那么在調(diào)用棧中的調(diào)用幀始終只有一條桐智,這樣會節(jié)省很大一部分的內(nèi)存末早,這也是尾調(diào)用優(yōu)化的意義。

function f() {
  let m = 1;
  let n = 2;
  return g(m + n);
}
f();

// 等同于
function f() {
  return g(3);
}
f();

// 等同于
g(3);

上面代碼中酵使,如果函數(shù)g不是尾調(diào)用荐吉,函數(shù)f就需要保存內(nèi)部變量m和n的值、g的調(diào)用位置等信息口渔。但由于調(diào)用g之后,函數(shù)f就結(jié)束了穿撮,所以執(zhí)行到最后一步缺脉,完全可以刪除f(x)的調(diào)用幀,只保留g(3)的調(diào)用幀悦穿。

注意攻礼,只有不再用到外層函數(shù)的內(nèi)部變量,內(nèi)層函數(shù)的調(diào)用幀才會取代外層函數(shù)的調(diào)用幀栗柒,否則就無法進(jìn)行“尾調(diào)用優(yōu)化”礁扮。

function addOne(a){
  var one = 1;
  function inner(b){
    return b + one;
  }
  return inner(a);
}

上面的函數(shù)不會進(jìn)行尾調(diào)用優(yōu)化,因?yàn)閮?nèi)層函數(shù)inner用到了外層函數(shù)addOne的內(nèi)部變量one瞬沦。

尾調(diào)用的應(yīng)用——尾遞歸Tail Recursion

函數(shù)調(diào)用自身太伊,稱為遞歸。如果尾調(diào)用自身逛钻,就稱為尾遞歸僚焦。
遞歸非常耗費(fèi)內(nèi)存,因?yàn)樾枰瑫r保存成千上百個調(diào)用幀曙痘,很容易發(fā)生“棧溢出”錯誤(stack overflow)芳悲。但對于尾遞歸來說,由于只存在一個調(diào)用幀边坤,所以永遠(yuǎn)不會發(fā)生“棧溢出”錯誤名扛。

function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

上面代碼是一個階乘函數(shù),計算n的階乘茧痒,最多需要保存n個調(diào)用記錄肮韧,空間復(fù)雜度 O(n) 。

如果改寫成尾遞歸,只保留一個調(diào)用記錄惹苗,空間復(fù)雜度 O(1) 殿较。

function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

還有一個比較著名的例子,就是計算 Fibonacci 數(shù)列桩蓉,也能充分說明尾遞歸優(yōu)化的重要性淋纲。

非尾遞歸的 Fibonacci 數(shù)列實(shí)現(xiàn)如下。

function Fibonacci (n) {
  if ( n <= 1 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89
Fibonacci(100) // 超時
Fibonacci(500) // 超時

尾遞歸優(yōu)化過的 Fibonacci 數(shù)列實(shí)現(xiàn)如下院究。

function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {return ac2};

  return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}

Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

由此可見洽瞬,“尾調(diào)用優(yōu)化”對遞歸操作意義重大,所以一些函數(shù)式編程語言將其寫入了語言規(guī)格业汰。ES6 亦是如此伙窃,第一次明確規(guī)定,所有 ECMAScript 的實(shí)現(xiàn)样漆,都必須部署“尾調(diào)用優(yōu)化”为障。這就是說,ES6 中只要使用尾遞歸放祟,就不會發(fā)生棧溢出(或者層層遞歸造成的超時)鳍怨,相對節(jié)省內(nèi)存。

遞歸函數(shù)的改寫

尾遞歸的實(shí)現(xiàn)跪妥,往往需要改寫遞歸函數(shù)鞋喇,確保最后一步只調(diào)用自身。做到這一點(diǎn)的方法眉撵,就是把所有用到的內(nèi)部變量改寫成函數(shù)的參數(shù)侦香。比如上面的例子,階乘函數(shù) factorial 需要用到一個中間變量total纽疟,那就把這個中間變量改寫成函數(shù)的參數(shù)罐韩。這樣做的缺點(diǎn)就是不太直觀,第一眼很難看出來仰挣,為什么計算5的階乘伴逸,需要傳入兩個參數(shù)5和1?

兩個方法可以解決這個問題膘壶。方法一是在尾遞歸函數(shù)之外错蝴,再提供一個正常形式的函數(shù)。

function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

function factorial(n) {
  return tailFactorial(n, 1);
}

factorial(5) // 120

上面代碼通過一個正常形式的階乘函數(shù)factorial颓芭,調(diào)用尾遞歸函數(shù)tailFactorial顷锰,看起來就正常多了。

函數(shù)式編程有一個概念亡问,叫做柯里化(currying)官紫,意思是將多參數(shù)的函數(shù)轉(zhuǎn)換成單參數(shù)的形式肛宋。這里也可以使用柯里化。

function currying(fn, n) {
  return function (m) {
    return fn.call(this, m, n);
  };
}

function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120

上面代碼通過柯里化束世,將尾遞歸函數(shù)tailFactorial變?yōu)橹唤邮芤粋€參數(shù)的factorial酝陈。

第二種方法就簡單多了,就是采用 ES6 的函數(shù)默認(rèn)值毁涉。

function factorial(n, total = 1) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5) // 120

上面代碼中沉帮,參數(shù)total有默認(rèn)值1,所以調(diào)用時不用提供這個值贫堰。

總結(jié)一下穆壕,遞歸本質(zhì)上是一種循環(huán)操作。純粹的函數(shù)式編程語言沒有循環(huán)操作命令其屏,所有的循環(huán)都用遞歸實(shí)現(xiàn)喇勋,這就是為什么尾遞歸對這些語言極其重要。對于其他支持“尾調(diào)用優(yōu)化”的語言(比如 Lua偎行,ES6)川背,只需要知道循環(huán)可以用遞歸代替,而一旦使用遞歸蛤袒,就最好使用尾遞歸渗常。

尾遞歸優(yōu)化(Tail Call Optimization)

尾遞歸優(yōu)化只在嚴(yán)格模式下生效,那么正常模式下汗盘,或者那些不支持該功能的環(huán)境中,有沒有辦法也使用尾遞歸優(yōu)化呢询一?回答是可以的隐孽,就是自己實(shí)現(xiàn)尾遞歸優(yōu)化。

它的原理非常簡單健蕊。尾遞歸之所以需要優(yōu)化菱阵,原因是調(diào)用棧太多,造成溢出缩功,那么只要減少調(diào)用棧晴及,就不會溢出。怎么做可以減少調(diào)用棧呢嫡锌?就是采用“循環(huán)”換掉“遞歸”虑稼。

下面是一個正常的遞歸函數(shù)。

function sum(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1);
  } else {
    return x;
  }
}

sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)

上面代碼中势木,sum是一個遞歸函數(shù)蛛倦,參數(shù)x是需要累加的值,參數(shù)y控制遞歸次數(shù)啦桌。一旦指定sum遞歸 100000 次溯壶,就會報錯,提示超出調(diào)用棧的最大次數(shù)。
蹦床函數(shù)(trampoline)可以將遞歸執(zhí)行轉(zhuǎn)為循環(huán)執(zhí)行且改。

function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

上面就是蹦床函數(shù)的一個實(shí)現(xiàn)验烧,它接受一個函數(shù)f作為參數(shù)。只要f執(zhí)行后返回一個函數(shù)又跛,就繼續(xù)執(zhí)行碍拆。注意,這里是返回一個函數(shù)效扫,然后執(zhí)行該函數(shù)倔监,而不是函數(shù)里面調(diào)用函數(shù),這樣就避免了遞歸執(zhí)行菌仁,從而就消除了調(diào)用棧過大的問題浩习。

然后,要做的就是將原來的遞歸函數(shù)济丘,改寫為每一步返回另一個函數(shù)谱秽。

function sum(x, y) {
  if (y > 0) {
    return sum.bind(null, x + 1, y - 1);
  } else {
    return x;
  }
}

上面代碼中,sum函數(shù)的每次執(zhí)行摹迷,都會返回自身的另一個版本疟赊。
現(xiàn)在,使用蹦床函數(shù)執(zhí)行sum峡碉,就不會發(fā)生調(diào)用棧溢出近哟。

trampoline(sum(1, 100000))
// 100001

蹦床函數(shù)并不是真正的尾遞歸優(yōu)化,下面的實(shí)現(xiàn)才是鲫寄。

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];

  return function accumulator() {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = f.apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

var sum = tco(function(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1)
  }
  else {
    return x
  }
});

sum(1, 100000)
// 100001

上面代碼中吉执,tco函數(shù)是尾遞歸優(yōu)化的實(shí)現(xiàn),它的奧妙就在于狀態(tài)變量active地来。默認(rèn)情況下戳玫,這個變量是不激活的。一旦進(jìn)入尾遞歸優(yōu)化的過程未斑,這個變量就激活了咕宿。然后,每一輪遞歸sum返回的都是undefined蜡秽,所以就避免了遞歸執(zhí)行府阀;而accumulated數(shù)組存放每一輪sum執(zhí)行的參數(shù),總是有值的载城,這就保證了accumulator函數(shù)內(nèi)部的while循環(huán)總是會執(zhí)行肌似。

這樣就很巧妙地將“遞歸”改成了“循環(huán)”,而后一輪的參數(shù)會取代前一輪的參數(shù)诉瓦,保證了調(diào)用棧只有一層川队。

參考:
wikipedia.org
尾調(diào)用優(yōu)化 - 阮一峰
JS 調(diào)用棧機(jī)制與 ES6 尾調(diào)用優(yōu)化介紹
https://segmentfault.com/a/1190000020694801

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末力细,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子固额,更是在濱河造成了極大的恐慌眠蚂,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,188評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件斗躏,死亡現(xiàn)場離奇詭異逝慧,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)啄糙,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,464評論 3 395
  • 文/潘曉璐 我一進(jìn)店門笛臣,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人隧饼,你說我怎么就攤上這事沈堡。” “怎么了燕雁?”我有些...
    開封第一講書人閱讀 165,562評論 0 356
  • 文/不壞的土叔 我叫張陵诞丽,是天一觀的道長。 經(jīng)常有香客問我拐格,道長僧免,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,893評論 1 295
  • 正文 為了忘掉前任捏浊,我火速辦了婚禮懂衩,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘金踪。我一直安慰自己勃痴,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,917評論 6 392
  • 文/花漫 我一把揭開白布热康。 她就那樣靜靜地躺著,像睡著了一般劣领。 火紅的嫁衣襯著肌膚如雪姐军。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,708評論 1 305
  • 那天尖淘,我揣著相機(jī)與錄音奕锌,去河邊找鬼。 笑死村生,一個胖子當(dāng)著我的面吹牛惊暴,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播趁桃,決...
    沈念sama閱讀 40,430評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼辽话,長吁一口氣:“原來是場噩夢啊……” “哼肄鸽!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起油啤,我...
    開封第一講書人閱讀 39,342評論 0 276
  • 序言:老撾萬榮一對情侶失蹤典徘,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后益咬,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體逮诲,經(jīng)...
    沈念sama閱讀 45,801評論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,976評論 3 337
  • 正文 我和宋清朗相戀三年幽告,在試婚紗的時候發(fā)現(xiàn)自己被綠了梅鹦。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,115評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡冗锁,死狀恐怖齐唆,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情蒿讥,我是刑警寧澤蝶念,帶...
    沈念sama閱讀 35,804評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站芋绸,受9級特大地震影響媒殉,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜摔敛,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,458評論 3 331
  • 文/蒙蒙 一廷蓉、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧马昙,春花似錦桃犬、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,008評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至子房,卻和暖如春形用,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背证杭。 一陣腳步聲響...
    開封第一講書人閱讀 33,135評論 1 272
  • 我被黑心中介騙來泰國打工田度, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人解愤。 一個月前我還...
    沈念sama閱讀 48,365評論 3 373
  • 正文 我出身青樓镇饺,卻偏偏與公主長得像,于是被迫代替她去往敵國和親送讲。 傳聞我的和親對象是個殘疾皇子奸笤,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,055評論 2 355

推薦閱讀更多精彩內(nèi)容