Kotlin 內(nèi)聯(lián)類 inline class請了解一下

最近在做開發(fā)的工作中,意外發(fā)現(xiàn)了kotlin官方承認的一個內(nèi)聯(lián)類的bug鞍泉。在理解這個bug產(chǎn)生的原因的過程中皱埠,我秉承著打破砂鍋問到底的決心,竟然順勢學習了一波jvm字節(jié)碼咖驮。收獲頗豐边器,于是便開始著手寫下這篇文章和大家分享一下這個學習的過程。這篇文章很長托修,但是耐心看完忘巧,我相信大家肯定會覺得很值。

聽說inline class很屌

事情是這樣的睦刃。團隊的領(lǐng)頭大哥上周給我安利了一波kotlin的內(nèi)聯(lián)類袋坑,說這玩意好用的很,節(jié)約內(nèi)存眯勾。于是順手寫了一個sample給我看看。還沒了解過內(nèi)聯(lián)類(inline class)的可以看看官方文檔

有時候婆誓,業(yè)務(wù)邏輯需要圍繞某種類型創(chuàng)建包裝器吃环。然而,由于額外的堆內(nèi)存分配問題洋幻,它會引入運行時的性能開銷郁轻。此外,如果被包裝的類型是原生類型文留,性能的損失是很糟糕的好唯,因為原生類型通常在運行時就進行了大量優(yōu)化,然而他們的包裝器卻沒有得到任何特殊的處理燥翅。

簡單來說骑篙,就是比如我定義了一個Password類

class Password{
    private String password;
    public Password(String p){
        this.password = p
    }
}

這種數(shù)據(jù)包裝類效率很低,而且占內(nèi)存森书。因為這個類實際上只包裝了一個String的數(shù)據(jù)靶端,但是因為他是一個單獨聲明的類,所以如果new Password()的話還需要單獨給這個類創(chuàng)建一個實例凛膏,放在jvm的heap 內(nèi)存里杨名。

如果有一種辦法,既可以讓這個數(shù)據(jù)類保持它單獨的類型猖毫,又不那么占空間台谍,那豈不是完美?用inline class就是一個很好的選擇吁断。

inline class Password(val value: String)
// 不存在 'Password' 類的真實實例對象
// 在運行時趁蕊,'securePassword' 僅僅包含 'String'
val securePassword = Password("Don't try this in production")

Kotlin會在編譯的時候檢查inline class的類型坞生,但是在運行時runtime僅僅包含String數(shù)據(jù)。(至于它為啥這么屌介衔,下面會通過字節(jié)碼分析)

那既然這個類這么好用恨胚,我就開始試試了。

inline class的坑

俗話說得好炎咖,試試就逝世赃泡。沒多久我就發(fā)現(xiàn)一個很奇葩的現(xiàn)象。示例代碼如下

我先定義了一個inline class

inline class ICAny constructor(val value: Any)

這個類僅僅是一個包裝類乘盼,包裝一個任意類型的value(在jvm里面就是Object)

interface A {
    fun foo(): Any
}

同時定義一個interface, foo方法返回任意類型升熊。

class B : A {
    override fun foo(): ICAny {
        return ICAny(1)
    }
}

接著實現(xiàn)這個interface,在重載的foo的返回值上面我們返回剛剛定義的inline class類绸栅。因為ICAny肯定是Any(在jvm里面是Object)的子類级野,所以這個方法是能夠通過編譯的。

接下來神奇的事情發(fā)生了粹胯。

在調(diào)用下面的代碼的時候

 fun test(){
        val foo2: Any = (B() as A).foo()
        println(foo2 is ICAny)
    }

打印結(jié)果竟然是False蓖柔!

也就是說,foo2這個變量风纠,不是ICAny類况鸣。

這就很神奇了,class B的foo已經(jīng)是明確的返回一個ICAny的實例了竹观,哪怕我做一個向上轉(zhuǎn)型,也不應(yīng)該影響foo2這個變量在運行時的類型啊镐捧。

字節(jié)碼有問題么?

雖然我不太懂字節(jié)碼臭增,但是我的直覺告訴我應(yīng)該順便看一眼懂酱,于是我便隨手使用Intelji的kotlin字節(jié)碼功能,打開了這段代碼的字節(jié)碼誊抛。

Screenshot 2021-05-23 at 10.29.16 PM.png

一看列牺,好家伙,除了instanceOf這個方法需要判斷ICAny類之外拗窃,沒有一段字節(jié)碼和ICAny類有關(guān)昔园。

Screenshot 2021-05-23 at 10.30.09 PM.png

我的直覺是,既然B類的foo方法返回的是ICAny類實例并炮,那調(diào)用這個方法的代碼塊怎么也得有一個變量是這個ICAny類吧默刚。結(jié)果是編譯好的字節(jié)碼竟然完全沒有ICAny類什么事。著實奇怪逃魄。

字節(jié)碼入門

為了徹底搞明白這到底是為啥荤西。我決定要開始入門一些字節(jié)碼的知識。。邪锌。勉躺。網(wǎng)上關(guān)于字節(jié)碼的資料很多,這里我就只分享一下和我們這次bug有關(guān)的知識觅丰。

首先字節(jié)碼看起來有點像學過的匯編語言一樣饵溅,比二進制要容易懂,但是又比高級語言晦澀一些妇萄,而且都是用有限的指令集實現(xiàn)高級語言功能蜕企。最后,最重要的一點冠句,大部分JVM都是用棧來實現(xiàn)字節(jié)碼的轻掩。我們接下來用例子詳細了解一下這個棧到底是啥。

class Test {
    fun test(){
        val a = 1;
        val b = 1;
        val c = a + b
    }
}

比如上面這個簡單的test方法懦底,變成字節(jié)碼之后唇牧,長這個樣子

public final test()V
   L0
    LINENUMBER 3 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 4 L1
    ICONST_1
    ISTORE 2
   L2
    LINENUMBER 5 L2
    ILOAD 1
    ILOAD 2
    IADD
    ISTORE 3
   L3
    LINENUMBER 6 L3
    RETURN
   L4
    LOCALVARIABLE c I L3 L4 3
    LOCALVARIABLE b I L2 L4 2
    LOCALVARIABLE a I L1 L4 1
    LOCALVARIABLE this LTest; L0 L4 0
    MAXSTACK = 2
    MAXLOCALS = 4

看起來好像很復(fù)雜,其實非常容易理解,我們一個指令一個指令的看聚唐。具體哪個指令是干什么的丐重,我們參照這個JVM指令集表格 https://en.wikipedia.org/wiki/Java_bytecode_instruction_listings

第一步L0

Screenshot 2021-05-24 at 10.16.02 PM.png

ICONST_1,在字節(jié)碼里面定義為

load the int value 1 onto the stack

那么當前的棧幀就有了第一個數(shù)據(jù),1

Screenshot 2021-05-24 at 10.43.10 PM.png

第二步是 ISTORE 1 杆查,在字節(jié)碼里面ISTORE的定義為

store int value into variable #index, It is popped from the operand stack, and the value of the local variable at index is set to value.

意思就是這個操作會把棧中的頂端數(shù)字pop出來弥臼,然后賦予index為1的變量。那index為1的變量是哪個變量根灯?字節(jié)碼的第四部分已經(jīng)給出了答案。就是變量 a

Screenshot 2021-05-24 at 10.22.26 PM.png

同時掺栅,因為ISTORE會pop棧頂數(shù)字烙肺,此時棧變空了。

Screenshot 2021-05-24 at 10.23.50 PM.png

字節(jié)碼的第二部分和第一部分幾乎一模一樣氧卧,只是賦值變量從a變成了b(注意ISTORE的參數(shù)是2桃笙,對應(yīng)index為2的變量,就是b)

 L1
    LINENUMBER 4 L1
    ICONST_1
    ISTORE 2

字節(jié)碼第三部分

 L2
    LINENUMBER 5 L2
    ILOAD 1
    ILOAD 2
    IADD
    ISTORE 3

第一二個指令是ILOAD沙绝,定義為

load an int value from a local variable #index, The value of the local variable at index is pushed onto the operand stack.

也就是說搏明,這個指令會獲取index為1和2的變量的值,并且把值放入棧頂闪檬。

那么經(jīng)過ILOAD 1和ILOAD 2之后星著,棧內(nèi)元素變成了

Screenshot 2021-05-24 at 10.29.05 PM.png

第三個指令是IADD

add two ints, The values are popped from the operand stack. The int result is value1 + value2. The result is pushed onto the operand stack.

也就是說,這個指令會把棧頂?shù)膬蓚€元素分別pop出來并且相加粗悯,相加的和再放入棧中虚循,也就是說此時棧內(nèi)元素變成了

Screenshot 2021-05-24 at 10.30.51 PM.png

最后一步

ISTORE 3

也就是把棧頂元素賦值給index為3的變量,也就是c, 最后横缔,c被賦值為2.

以上铺遂,就是字節(jié)碼的基礎(chǔ),它以棧為容器茎刚,處理每個指令的返回值(也可能沒有返回值)襟锐。同時,JVM的大部分指令膛锭,都是從棧頂獲取參數(shù)作為輸入粮坞。這個設(shè)計,使得JVM可以在單個棧里面處理一個方法的運行泉沾。

為了能讓大家更深刻的理解這個棧的使用方式捞蚂,我這里留一個小作業(yè)。理解了這個小作業(yè)的原理跷究,咱再繼續(xù)往下看姓迅。不然就多研究一下。務(wù)必徹底理解透徹JVM中棧的使用方法才行俊马。

作業(yè)

一個簡單的代碼

 fun test(){
        val a = Object()
    }

字節(jié)碼為

  LINENUMBER 3 L0
    NEW java/lang/Object
    DUP
    INVOKESPECIAL java/lang/Object.<init> ()V
    ASTORE 1

請問為什么在執(zhí)行完NEW指令之后丁存,需要使用DUP來復(fù)制剛剛NEW出來對象的reference到棧頂

inline class的字節(jié)碼?

在學習完字節(jié)碼基礎(chǔ)之后柴我,我就開始琢磨一下解寝,是不是該研究一下inline class的字節(jié)碼和普通類的字節(jié)碼有啥不同。

果然艘儒,在得到inline class的字節(jié)碼之后聋伦,神奇的東西出現(xiàn)了。

以下面這個inline class為例子

inline class ICAny(val a: Any)

字節(jié)碼中界睁,區(qū)別于普通的類觉增,這個inline class的構(gòu)造函數(shù)標記為了private,也就是外部代碼不能使用inline class的構(gòu)造函數(shù)翻斟。

Screenshot 2021-05-24 at 10.45.04 PM.png

但是在代碼中使用

    val a = ICAny(1)

卻是沒有錯誤的逾礁。很神奇。访惜。嘹履。债热。

第二焰枢,inline class多了一個叫constructor-impl的方法暑椰,看名字和構(gòu)造函數(shù)有關(guān)低滩,但是仔細看监憎,這個方法啥也沒干鲸阔,就是用ALOAD把輸入的參數(shù)讀取到棧之后叙身,又馬上彈出返回了.(注意該方法的輸入類型是Object)

Screenshot 2021-05-24 at 10.45.10 PM.png

帶著諸多疑問,我們來看看當我們創(chuàng)建一個inline class實例的時候,編譯器到底做了啥旁瘫。

 val a = ICAny(1)

上面這段kotlin代碼對應(yīng)的字節(jié)碼是:

 L0
    LINENUMBER 6 L0
    ICONST_1
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    INVOKESTATIC com/jetbrains/handson/mpp/myapplication/ICAny.constructor-impl (Ljava/lang/Object;)Ljava/lang/Object;
    ASTORE 1

神奇的地方就在于,這段字節(jié)碼完全沒有執(zhí)行過NEW指令。

NEW指令是用來分配內(nèi)存的权埠。NEW之后配合init(構(gòu)造函數(shù))可以完成一個對象的初始化龙屉。

比如我們創(chuàng)建一個HashMap:

 val a = HashMap<String,String>()

對應(yīng)的字節(jié)碼是:

L0
    LINENUMBER 10 L0
    NEW java/util/HashMap
    DUP
    INVOKESPECIAL java/util/HashMap.<init> ()V
    ASTORE 1

可以很明顯的看出來,字節(jié)碼先執(zhí)行NEW指令,劃分了內(nèi)存枢步。然后再執(zhí)行了HashMap的構(gòu)造函數(shù)init。這是一個創(chuàng)建對象的標準流程,很可惜的是從inline class的創(chuàng)建過程中我們完全看不到這個過程。也就是說宝惰,當我們寫出代碼:

val a = ICAny(1)

的時候炒瘸,JVM壓根都不會開辟新的堆內(nèi)存。這也解釋了為啥inline class在內(nèi)存上有優(yōu)勢汹胃,因為它只是從編譯的角度把值給包裝起來,不會創(chuàng)建類實例谤逼。

但是如果壓根都不創(chuàng)建類實例戚绕,那如果我們做instanceOf的操作,豈不是不能正常工作?

fun test(){
       val a = ICAny(1)
       if( a is ICAny){
           print("ok")
       }
   }

這段代碼的字節(jié)碼編譯出來的字節(jié)碼會被JVM優(yōu)化户辱,JVM編譯器根據(jù)上下文判斷a肯定是ICAny類鸵钝,所以在字節(jié)碼中你甚至都看不到有if的出現(xiàn),因為編譯器優(yōu)化之后會發(fā)現(xiàn)if一定是true庐镐。

Screenshot 2021-05-25 at 8.00.30 PM.png

inline class的裝箱拆箱

帶著疑惑恩商,我開始查看inline class的設(shè)計文檔。幸運的是必逆,jetbrian對這些設(shè)計文檔都是公開的怠堪。在設(shè)計文檔 中,jetbrian的工程師詳細的解釋了關(guān)于inline class的類型問題名眉。

原文是這樣描述的

Rules for boxing are pretty the same as for primitive types and can be formulated as follows: inline class is boxed when it is used as another type. Unboxed inline class is used when value is statically known to be inline class.

大概意思就是inline class也需要裝箱拆箱粟矿,就和Integer類和int類型一樣。在有需要的時候編譯器會對這兩種類型做轉(zhuǎn)換璧针,轉(zhuǎn)換的過程就是裝箱/拆箱嚷炉。

那對于inline class來說渊啰,什么時候需要拆箱探橱,什么時候需要裝箱呢申屹?上文已經(jīng)給出了解答:

inline class is boxed when it is used as another type

當inline class在runtime的時候被當成另一種類型使用的時候,就會裝箱隧膏。

Unboxed inline class is used when value is statically known to be inline class

當inline class 在靜態(tài)分析中被認為是當做inline class本身執(zhí)行的時候哗讥,就不需要裝箱。

可能這樣說有點繞口胞枕,我們用一個簡單的例子來闡明:

 fun test(){
       val a = ICAny(1)
       if( a is ICAny){
           print("ok")
       }
   }

上面這段代碼中杆煞,JVM編譯器在編譯階段就可以通過上下文的靜態(tài)分析得出a一定是ICAny類,這種情況就符合unbox的條件腐泻。因為編譯器在靜態(tài)分析階段就已經(jīng)獲取了類型信息决乎,我們就可以使用拆箱的inline class,也就是字節(jié)碼不會生成一個新的ICAny實例派桩。這樣也符合我們之前分析构诚。

但是假如我們修改一下使用方式:

    fun test() {
        val a = ICAny(1)
        bar(a)
    }

    private fun bar(a: Any) {
        if (a is ICAny) {
            print("ok")
        }
    }

加入了一個叫bar的方法,該方法的輸入是Any铆惑,也就是JVM中的Object類范嘱。這段代碼編譯出來的字節(jié)碼,就需要裝箱操作了

Screenshot 2021-05-25 at 8.45.40 PM.png

ICAny的裝箱操作方法员魏,和primitive type類似丑蛤,其實就是執(zhí)行NEW指令,創(chuàng)建一個新的類實例

Screenshot 2021-05-25 at 8.46.18 PM.png

總結(jié)一下撕阎,當使用inline class的時候受裹,如果當前代碼根據(jù)上下文可以推斷出變量一定是inline class類型,編譯器就可以優(yōu)化代碼闻书,不生成新的類實例名斟,從而達到節(jié)省內(nèi)存空間的目的。但是如果通過上下文推斷不出來變量是否是inline class魄眉,編譯器就會調(diào)用裝箱方法砰盐,創(chuàng)建新的inline class類實例,劃分內(nèi)存空間給inline class實例坑律,也就達不到所謂的節(jié)省內(nèi)存的目的了岩梳。

官方給出的例子如下

Screenshot 2021-05-25 at 8.50.18 PM.png

其中值得注意的是泛型也會讓inline class產(chǎn)生裝箱,因為泛型其實和kotlin的Any是一樣的性質(zhì)晃择,在JVM字節(jié)碼中都是Object冀值。

這也給大家提了個醒,如果你的代碼不能通過上下文判斷inline class類型宫屠,那使用inline class可能并沒啥卵用列疗。。浪蹂。抵栈。

inline class的bug是什么原因產(chǎn)生的

在了解完基礎(chǔ)知識之后告材,我們終于可以開始理解為什么在文章開始時候提到的bug會發(fā)生了。Kotlin官方已經(jīng)意識到這個bug并且把bug產(chǎn)生的原因詳細解釋了一下: https://youtrack.jetbrains.com/issue/KT-30419 (在這里非常欣賞jetbrian的工程師的作風古劲,可以說是寫的非常詳細了)斥赋。

這里稍微解釋一下給看不太懂英文的小伙伴:

在JVM中,kotlin和java都是支持多態(tài)/協(xié)變的产艾。比如在下面這個繼承關(guān)系中:

interface A {
    fun foo(): Any
}

class B : A {
    override fun foo(): String { // Covariant override, return type is more specialized than in the parent
        return ""
    }
}

這樣的代碼編譯是完全ok的疤剑,因為ICAny可以看做是繼承了Object類,所以Class B作為繼承A接口的實體類闷堡,重寫的方法的返回值可以是和接口類方法的返回值呈繼承關(guān)系的.

在class B的字節(jié)碼中隘膘,編譯器會生成一個橋接方法(bridge method)來讓重寫的foo方法返回String類,但是同時方法簽名維持父類的類型杠览。

Screenshot 2021-05-25 at 9.07.23 PM.png

JVM正是依靠著橋接方法棘幸,實現(xiàn)了繼承關(guān)系的協(xié)變。

但是到了inline class這里倦零,就出大問題了误续。對于inline class來說,因為編譯器會默認將其當做Object類型扫茅,會導致某些實體類沒法生成橋接方法的bug蹋嵌。

比如:

interface A {
    fun foo(): Any
}

class B : A {
    override fun foo(): ICAny {
        return ICAny(4)
    }
}

因為ICAny類在JVM中是Object類型,Any也是Object類型葫隙,編譯器就會自動認為重寫方法的返回值是和interface一樣栽烂,所以不會生成ICAny的橋接方法。

Screenshot 2021-05-25 at 9.12.59 PM.png

所以回到我們文章開頭的bug代碼中

       val foo2: Any = (B() as A).foo()
        println(foo2 is ICAny)

因為B沒有ICAny類型的橋接方法恋脚,加上在代碼中我們強制轉(zhuǎn)型把B轉(zhuǎn)成了A類腺办,所以靜態(tài)分析也會認為foo()方法的返回值是Any,就會導致foo2變量不會被裝箱糟描,所以類型就一直是Object怀喉,以上代碼的打印結(jié)果也就是False了。

Screenshot 2021-05-25 at 9.16.56 PM.png

所以相應(yīng)的船响,解決這個bug的辦法也很簡單躬拢,就是給inline class加上橋接方法就好了!

這個bug在kotlin 1.3的時候被發(fā)現(xiàn)见间,在1.4被fix聊闯。但是鑒于大部分安卓應(yīng)用開發(fā)還在使用1.3,這個坑可能還會長期存在米诉。

升級到kotlin1.5之后菱蔬,打開字節(jié)碼工具可以發(fā)現(xiàn)橋接方法已經(jīng)被加上啦:

Screenshot 2021-05-25 at 9.19.44 PM.png

總結(jié)

在理解這個bug的原因和解決方法的過程中,我開始嘗試了解字節(jié)碼,同時學習JVM的調(diào)用棧拴泌,最后拓展到字節(jié)碼對協(xié)變多態(tài)的支持犹褒,可以說收獲真的很多。希望這個學習方式和過程可以給更多的朋友一些啟發(fā)弛针,當我們遇到問題的時候,需要做到知其然李皇,還要知其所以然削茁,這么多年的經(jīng)驗告訴我,掌握好一門學科的基礎(chǔ)是可以讓之后的工作事半功倍的掉房。與大家共勉茧跋!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市卓囚,隨后出現(xiàn)的幾起案子瘾杭,更是在濱河造成了極大的恐慌,老刑警劉巖哪亿,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件粥烁,死亡現(xiàn)場離奇詭異,居然都是意外死亡蝇棉,警方通過查閱死者的電腦和手機讨阻,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來篡殷,“玉大人钝吮,你說我怎么就攤上這事“辶桑” “怎么了奇瘦?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長劲弦。 經(jīng)常有香客問我耳标,道長,這世上最難降的妖魔是什么邑跪? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任麻捻,我火速辦了婚禮,結(jié)果婚禮上呀袱,老公的妹妹穿的比我還像新娘贸毕。我一直安慰自己,他們只是感情好夜赵,可當我...
    茶點故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布明棍。 她就那樣靜靜地躺著,像睡著了一般寇僧。 火紅的嫁衣襯著肌膚如雪摊腋。 梳的紋絲不亂的頭發(fā)上沸版,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天,我揣著相機與錄音兴蒸,去河邊找鬼视粮。 笑死,一個胖子當著我的面吹牛橙凳,可吹牛的內(nèi)容都是我干的蕾殴。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼岛啸,長吁一口氣:“原來是場噩夢啊……” “哼钓觉!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起坚踩,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤荡灾,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后瞬铸,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體批幌,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年嗓节,在試婚紗的時候發(fā)現(xiàn)自己被綠了逼裆。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡赦政,死狀恐怖胜宇,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情恢着,我是刑警寧澤桐愉,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站掰派,受9級特大地震影響从诲,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜靡羡,卻給世界環(huán)境...
    茶點故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一系洛、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧略步,春花似錦描扯、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春恩够,著一層夾襖步出監(jiān)牢的瞬間卒落,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工蜂桶, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留儡毕,地道東北人。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓扑媚,卻偏偏與公主長得像腰湾,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子钦购,可洞房花燭夜當晚...
    茶點故事閱讀 44,577評論 2 353

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