C#基础提升系列——C#泛型

C# 泛型(Generics)

泛型概述

泛型是C#编程语言的一部分,它与程序集中的IL(Intermediate Language,中间语言)代码紧密的集成。通过泛型,我们不必给不同的类型编写功能相同的许多方法和类,而是可以创建独立于被包含类型的一个类或方法。 例如,通过使用泛型类型参数 T,可以编写其他客户端代码能够使用的单个类,而不会产生运行时转换或装箱操作的成本或风险。使用泛型类型可以最大限度地重用代码、保护类型安全性以及提高性能。

泛型性能

泛型的一个主要优点是性能。值类型存储在栈上,引用类型存储在堆上。从值类型转换为引用类型称为装箱;从引用类型转换为值类型称为拆箱。对值类型使用非泛型集合类,常常需要将值类型和引用类型互相转换,进行装箱和拆箱操作,性能损失比较大。而使用了泛型,可以很好的解决这一问题,泛型可以不再进行装箱和拆箱操作。

泛型类型安全

泛型的另一个特性是类型安全。例如,在泛型类List<T>中,泛型类型T定义了允许使用的类型。假设有一个泛型实例为List<int>,它在添加元素时,就只会添加类型为int的数值到集合中。

泛型允许二进制代码重用

泛型允许更好的重用二进制代码,泛型类可以定义一次,使用许多不同的类型实例化。例如,泛型类List<T>可以实例化为List<int>List<string>List<MyClass>等。

泛型实例化时代码生成

泛型类的定义会放在程序集 中,所以用特定类型实例化泛型类不会在中间语言(IL)代码中复制这些类。但是,在JIT编译器把泛型类编译为本地代码时,会给每个值类型创建一个新类。而引用类型共享同一个本地类的所有相同的实现代码。这是因为引用类型在实例化泛型类中只需要4个字节的内存地址(32位系统),就可以引用一个引用类型。值类型包含在实例化的泛型类的内存中,同时因为每个值类型对内存的要求都不同,所以要为每个值类型实例化一个新类。

注:【本段文字来自于《C#高级编程(第10版)》中的”不同的特定类型实例化泛型时创建了多少代码“相关描述】

泛型类型命名约定

  • 泛型类型的名称用字母T作为前缀。
  • 如果没有特殊的要求,泛型类型允许用任意类替代,且只使用了一个泛型类型,就可以用字符T作为泛型类型的名称。例如:public class List<T>{}
  • 如果泛型类型有特定的要求(例如,它必须实现一个接口或派生自基类),或者使用了两个或多个泛型类型,就应给泛型类型使用描述性的 名称。例如:public class SortedList<Tkey,TValue>{}

泛型类

泛型类型:也被称为泛型类型参数,它是在实例化泛型类的一个变量时,泛型声明中指定的特定类型的占位符,即泛型类中指定的T。

泛型类:定义泛型类型的类,例如List<T>,它无法按原样使用,因为它不是真正的类型;它更像是类型的蓝图。 若要使用 List<T>,客户端代码必须通过指定尖括号内的类型参数来声明并实例化构造类型。

创建泛型类

通常,创建泛型类是从现有具体类开始,然后每次逐个将类型更改为类型参数,直到泛化和可用性达到最佳平衡。 在创建泛型类之前,先建立一个简单的普通类,然后再把这个类转化为泛型类。

定义一个一般的、非泛型的简化链表类:

public class LinkedListNode
{
    public object Value { get; private set; }
    public LinkedListNode(object value)
    {
        Value = value;
    }
    public LinkedListNode Prev { get; internal set; }
    public LinkedListNode Next { get; internal set; }
}
public class LinkedList : IEnumerable
{
    public LinkedListNode First { get; private set; }
    public LinkedListNode Last { get; private set; }

    //在链表尾部添加一个新元素
    public LinkedListNode AddLast(object node)
    {
        var newNode = new LinkedListNode(node);
        if (First == null)
        {
            First = newNode;
            Last = First;
        }
        else
        {
            LinkedListNode previous = Last;
            Last.Next = newNode;
            Last = newNode;
            Last.Prev = previous;
        }
        return newNode;
    }

    //实现GetEnumerator()方法
    public IEnumerator GetEnumerator()
    {
        LinkedListNode current = First;
        while (current != null)
        {
            //使用yield语句创建一个枚举器类型
            yield return current.Value;
            current = current.Next;
        }
    }
}

当调用上述LinkedList类的AddLast()方法传入任意类型的值时,会进行一系列的装箱和拆箱的操作

var list1 = new LinkedList();
list1.AddLast(2);
list1.AddLast(3);
list1.AddLast("4");
foreach (var i in list1)
{
    Console.WriteLine(i);
}

使用泛型定义上述类

public class LinkedListNode<T>
{
    public LinkedListNode(T value)
    {
        Value = value;
    }

    public LinkedListNode<T> Next { get; internal set; }
    public LinkedListNode<T> Prev { get; internal set; }
    public T Value { get; private set; }
}

public class LinkedList<T> : IEnumerable<T>
{
    public LinkedListNode<T> First { get; private set; }
    public LinkedListNode<T> Last { get; private set; }

    public LinkedListNode<T> AddLast(T node)
    {
        var newNode = new LinkedListNode<T>(node);
        if (First == null)
        {
            First = newNode;
            Last = First;
        }
        else
        {
            LinkedListNode<T> previous = Last;
            Last.Next = newNode;
            Last = newNode;
            Last.Prev = previous;
        }
        return newNode;
    }

    public IEnumerator<T> GetEnumerator()
    {
        LinkedListNode<T> current = First;
        while (current != null)
        {
            yield return current.Value;
            current = current.Next;
        }
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

泛型类的定义与一般类类似,只是要使用泛型类型声明。声明后的泛型类型可以在类中用作方法或字段成员的参数类型。

调用上述中声明的方法用例如下,此时添加元素和遍历元素时都不用频繁的装箱和拆箱:

var list2 = new LinkedList<string>();
list2.AddLast("java");
list2.AddLast("c#");
list2.AddLast("python");
foreach (string i in list2)
{
    Console.WriteLine(i);
}

泛型类功能

在创建泛型类时,可以为泛型类型指定默认值、约束、继承和静态成员等。

创建如下一个简单泛型类 ,用于从队列中读写文档。

public class DocumentManager<T>
{
    private readonly Queue<T> documentQueue = new Queue<T>();

    public bool IsDocumentAvailable => documentQueue.Count > 0;

    public void AddDocument(T doc)
    {
        lock (this)
        {
            documentQueue.Enqueue(doc);
        }
    }
}
//定义一个简单的接口
public interface IDocument
{
    string Title { get; set; }
    string Content { get; set; }
}
//实现该接口
public class Document : IDocument
{
    public Document(string title, string content)
    {
        this.Title = title;
        this.Content = content;
    }
    public string Content { get; set; }
    public string Title { get; set; }
}

泛型类型默认值

在上述类DocumentManager<T>中添加如下方法:

public T GetDocument()
{
    //default将泛型类型的值初始化为null或者0,取决于泛型类型是引用类型还是值类型。
    T doc = default(T);
    lock (this)
    {
        doc = documentQueue.Dequeue();
    }
    return doc;
}

该方法直接返回类型T的值,由于不能把null赋予泛型类型,原因是泛型类型可以实例化为值类型,而null只能用于引用类型,因此为了解决这个问题,使用了default关键字来代替T doc=null; 通过default关键字,可以自动的将null赋予引用类型,将0赋予值类型,而不用管T具体是哪种类型。

泛型类型约束

如果泛型类(定义泛型类型的类,如DocumentManager)需要调用泛型类型(T)中的方法,就必须添加约束。

例如,在泛型类DocumentManager<T>中添加DisplayAllDocuments()方法用于显示泛型类型T对应的Title值,需要强制进行类型转换,如下:

public void DisplayAllDocuments()
{
    foreach (T doc in documentQueue)
    {
        Console.WriteLine(((IDocument)doc).Title);
    }
}

一旦类型T没有实现IDocument接口,上述类型转换就会存在错误,此时最好的做法就是为泛型类添加一个约束:T必须实现IDocument接口。

public class DocumentManager<TDocument> where TDocument : IDocument
{
    private readonly Queue<TDocument> documentQueue = new Queue<TDocument>();

    public bool IsDocumentAvailable => documentQueue.Count > 0;

    public void AddDocument(TDocument doc)
    {
        lock (this)
        {
            documentQueue.Enqueue(doc);
        }
    }

    public TDocument GetDocument()
    {
        //default将泛型类型的值初始化为null或者0,取决于泛型类型是引用类型还是值类型。
        TDocument doc = default(TDocument);
        lock (this)
        {
            doc = documentQueue.Dequeue();
        }
        return doc;
    }
    public void DisplayAllDocuments()
    {
        foreach (TDocument doc in documentQueue)
        {
            Console.WriteLine(doc.Title);
        }
    }
}

注意:给泛型类型 添加约束时,最好包含泛型参数名称的一些信息,此示例使用TDocument来代替T。调用上述代码如下:

var dm = new DocumentManager<Document>();
dm.AddDocument(new Document("title A", "sample A"));
dm.AddDocument(new Document("title B", "sample B"));
dm.DisplayAllDocuments();
if (dm.IsDocumentAvailable)
{
    Document d = dm.GetDocument();
    Console.WriteLine(d.Content);
}

泛型类型支持的约束类型

约束 说明
where T:struct 类型参数必须是值类型。 可以指定除Nullable以外的任何值类型。
where T : unmanaged unmanaged 约束指定类型参数必须为“非托管类型”。 “非托管类型”不是引用类型,所以该约束指定类型参数不能是引用类型,并且任何嵌套级别均不能包含任何引用类型成员。
where T:class 类约束指定类型T必须是引用类型 。此约束还应用于任何类、接口、委托或数组类型。
where T:IFoo 指定类型T必须实现接口IFoo
where T:Foo 指定类型T必须派生自基类Foo
where T:new() 这是一个构造函数约束,指定类型T必须有一个默认构造函数。当与其他约束一起使用时,new() 约束必须最后指定。
where T1:T2 这个约束也可以指定,类型T1派生自泛型类型T2

某些约束是互斥的。 所有值类型必须具有可访问的无参数构造函数。 struct 约束包含 new() 约束,且 new() 约束不能与 struct 约束结合使用。 unmanaged 约束包含 struct 约束。 unmanaged 约束不能与 structnew() 约束结合使用。

从 C# 7.3 开始,可使用 unmanaged 约束来指定类型参数必须为“非托管类型”。 “非托管类型”不是引用类型,且任何嵌套级别都不包含引用类型字段。

注意:只能为默认构造函数定义构造函数约束,不能为其他构造函数定义构造函数约束。

使用泛型类型可以合并多个约束:

  public class MyClass<T>
        where T : IFoo, new(){    }

上述声明表示类型T必须实现IFoo接口,且必须有一个默认构造函数。

注意:在C#中,where子句不能定义必须由泛型类型实现的运算符。运算符不能在接口中定义。在where子句中,只能定义基类、接口、和默认构造函数。

泛型类型继承

泛型类型可以实现泛型接口,也可以派生自一个泛型基类,其要求是必须重复基类的泛型类型,或者必须指定基类的类型。

public class Base<T> { }
public class Derived<T> : Base<T> { }
public class Derived_2<T> : Base<string> { }

派生类(子类)可以是泛型类或非泛型类,例如,可以定义一个抽象的泛型基类,它在派生类中用一个具体的类实现:

public abstract class Calc<T>
{
    public abstract T Add(T x, T y);
    public abstract T Sub(T x, T y);
}

public class IntCalc : Calc<int>
{
    public override int Add(int x, int y) => x + y;
    public override int Sub(int x, int y) => x - y;
}

还可以创建一个部分的特殊操作,如下:

public class Query<TRequest, TResult> { }
public class StringQuery<TRequest> : Query<TRequest, string> { }

上述中StringQuery继承自Query,只定义了一个泛型参数,为基类的TResult指定为string,要实例化StringQuery,只需要提供TRequest的类型。

泛型静态成员

应该减少泛型静态成员的使用,泛型类的静态成员只能在对应的同一个类实例中共享。

public class StaticDemo<T>
{
    public static T x; //此处变量x为T类型
    public static int y;
    //泛型静态成员调用
    StaticDemo<string>.x = "abc";
    StaticDemo<int>.x = 13;
    StaticDemo<string>.y = 2;
    StaticDemo<int>.y = 10;

    Console.WriteLine(StaticDemo<string>.x); //结果:abc
    Console.WriteLine(StaticDemo<int>.x);    //结果:13
    Console.WriteLine(StaticDemo<string>.y); //结果:2
    Console.WriteLine(StaticDemo<int>.y);    //结果:10
}

泛型接口

使用泛型可以定义接口,在接口中定义的方法可以带泛型参数。.NET提供了许多泛型接口,同一个接口常常存在比较老的非泛型版本,建议在实际使用中,优先采用泛型版本去解决问题。

泛型接口中的协变和逆变

为了更好的解释协变和逆变的概念,我们使用ListIListIEnumerable三者做一个简单的测验。首先我们定义一个List<string> 实例变量listA,并将listA的值指向IList<string>的变量iListA,同时分别使用IEnumerable<string>去引用这两个变量。

List<string> listA = new List<string>();
IList<string> iListA = listA;
IEnumerable<string> iEnumerableA = listA;
IEnumerable<string> iEnumerableB = iListA;

此时代码不会产生错误,能够正常编译。因为List<T>派生自IList<T>IEnumerable<T>IList<T>派生自IEnumerable<T>,父类引用指向子类对象,所以代码可以通过编译。

注意:IEnumerable<T>实际上是一个变体,查看定义的源码如下:

public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

特别需要注意的是泛型类型T前面的out关键字,它代表的就是协变。它的作用是什么?

C#中的string派生自Object类型,假设我们也想通过object的集合直接去引用stringList,类似于如下代码:

List<object> listB = new List<string>(); //报错,不会通过编译
IList<object> iListB = new List<string>(); //报错,不会通过编译

上述的两条语句均会编译失败,因为List<T>IList<T>在泛型定义时均没有指定out关键字。而使用IEnumerable<T>可以通过编译:

IEnumerable<object> iEnumerableB = new List<string>();//代码可以正常编译

上述语句可以通过编译。

注意:只有引用类型才支持使用泛型接口中的变体。 值类型不支持变体。 如下语句将会编译报错:

IEnumerable<object> integers = new List<int>();//编译错误,值类型不支持变体

下面将用具体的示例对协变和逆变做详细说明。首先定义两个简单的类,其中Rectangle继承自父类Shape

public class Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    //重写Object的ToString方法
    public override string ToString() => $"Width:{Width},Height:{Height}";
}
//定义子类Rectangle
public class Rectangle : Shape { }

泛型接口的协变

如果泛型类型使用out关键字标注,该泛型接口就是协变的。

public interface IIndex<out T>
{
    //定义一个索引器
    T this[int index] { get; }
    int Count { get; }
}
public class RectangleCollection : IIndex<Rectangle>
{
    private Rectangle[] data = new Rectangle[3] {
         new Rectangle{Height=2,Width=5},
         new Rectangle{ Height=3, Width=7},
         new Rectangle{ Height=4.5, Width=2.9}
    };
    public int Count => data.Length;

    public Rectangle this[int index]
    {
        get
        {
            if (index < 0 || index > data.Length)
            {
                throw new ArgumentOutOfRangeException("index");
            }
            return data[index];
        }
    }
}

上述定义了一个泛型接口IIndex,并使用out标注为协变,接着定义类RectangleCollection实现该接口,调用上述代码如下:

IIndex<Rectangle> rectangles = new RectangleCollection();
//由于采用了协变,此处可以直接使用父类Shape相关的引用指向子类Rectangle相关的对象
IIndex<Shape> shapes = rectangles;
IIndex<Shape> shapes2 = new RectangleCollection();
for (int i = 0; i < shapes.Count; i++)
{
    Console.WriteLine(shapes[i]);
}

泛型接口的逆变

使用in关键字标注泛型类型的接口就是逆变的。

public interface IDisplay<in T>
{
    void Show(T item);
}
public class ShapeDisplay : IDisplay<Shape>
{
    public void Show(Shape item)
    {
        Console.WriteLine($"{item.GetType().Name}  Width:{item.Width},Height:{item.Height}");
    }
}

上述定义了一个逆变的泛型接口IDisplay,并使用ShapeDisplay实现它,注意实现时指定的类型是Shape,并且定义了Show方法,显示对应Type名。调用代码如下:

IDisplay<Shape> sd = new ShapeDisplay();
//由于采用了逆变,可以使用Rectangle相关的引用指向父类Shape相关的对象
IDisplay<Rectangle> rectangleDisplay = sd;
rectangleDisplay.Show(rectangles[0]); //Type将会输出为Rectangle

下面是我自己的理解做的一个总结

协变:使用out关键字标注,协助变换,既然是协助就说明是客观存在的,也就是顺应"父类引用指向子类对象"这一原则所做的转换,协变会保留分配兼容性。协变允许方法具有的返回类型比接口的泛型类型参数定义的返回类型的派生程度更大。 在.net中,大多数的参数类型类似于协变 ,比如定义了一个方法,参数为object,调用该方法时,可以为参数传入所有object派生出的子类对象。

逆变:逆反变换,违背”父类引用指向子类对象“这一原则所做的转换,和协变相反,类似于“子类引用父类相关的对象”。逆变允许方法具有的实参类型比接口的泛型形参定义的类型的派生程度更小。比如定义一个方法,方法的参数为object,返回的类型为object的子类,此时不能直接返回传入的参数,必须进行类型转换,而逆变可以很好的解决此类问题。

变体:如果泛型接口或委托的泛型参数被声明为协变或逆变,该泛型接口或委托则被称为“变体”。

泛型方法

在泛型方法中,泛型类型用方法声明来定义。泛型方法可以在非泛型类中定义。如下,定义一个简单的泛型方法:

void Swap<T>(ref T x,ref T y)
{
    T temp;
    temp = x;
    x = y;
    y = temp;
}

注意定义的形式,泛型类型T需要在方法声明中(方法名的后面)指定。调用上述方法代码:

int a = 1, b = 2;
Swap<int>(ref a, ref b);
//C#编译器会通过调用该方法来获取参数的类型,所以不需要把泛型类型赋予方法调用,可简化为下述语句
Swap(ref a, ref b); //上述语句的简化写法

在调用泛型方法时,C#编译器会根据传入的参数自动获取类型,因此不需要把泛型类型赋予方法调用,即Swap<int>中的<int>可以不用指定(实际编码中,可以借助VS智能编码助手进行简化,使用ctrl+.快捷键进行调用)

带约束的泛型方法

在泛型类中,泛型类型可以用where子句来限制,同样,在泛型方法,也可以使用where子句来限制泛型类型。

public interface IAccount
{
    decimal Balance { get; }
    string Name { get; }
}
public class Account : IAccount
{
    public Account(string name, decimal balance)
    {
        Name = name;
        Balance = balance;
    }

    public decimal Balance { get; private set; }
    public string Name { get; }
}

上述定义一个简单的接口和实现的类,接着定义一个泛型方法,并且添加where子句约束,让泛型类型TAccount对应的传入参数必须实现接口IAccount

//静态类不能被实例化
public static class Algorithms
{
    public static decimal Accumulate<TAccount>(IEnumerable<TAccount> source)
        where TAccount : IAccount
    {
        decimal sum = 0;
        foreach (TAccount a in source)
        {
            sum += a.Balance;
        }
        return sum;
    }
}

调用上述方法的代码:

var accounts = new List<Account> {
    new Account("书籍",234),
    new Account("文具",56),
    new Account("手机",2300)
};
//decimal amount = Algorithms.Accumulate<Account>(accounts);
//编译器会从方法的参数类型中自动推断出泛型类型参数,可以简化为下述代码进行调用
decimal amount = Algorithms.Accumulate(accounts);

注意:并不是所有的方法调用都不需要指定泛型参数类型,当编译器无法自动推断出类型时,需要显式的进行指定,比如带委托的泛型方法。

泛型委托

这里我们用一个简单的例子来说明一下泛型委托的调用。关于委托,后续我会单独进行总结。

public static TSum Accumulate<TAccount, TSum>(
    IEnumerable<TAccount> source, //方法第一个参数
    Func<TAccount, TSum, TSum> action //方法第二个参数是一个委托
    ) where TAccount : IAccount where TSum : struct
{
    TSum sum = default(TSum);
    foreach (TAccount item in source)
    {
        sum = action(item, sum);
    }
    return sum;
}

该方法在声明时,指定了两个泛型类型TSumTAccount,其中一个约束是值类型,一个约束是实现接口IAccount,传入的第一个参数是IEnumerable<TAccount>类型的,第二个参数是一个委托。在调用该方法时,编译器不能自动推断出参数类型,需要显式的指定泛型参数类型,调用该方法代码如下:

var accounts = new List<Account> {
    new Account("书籍",234),
    new Account("文具",56),
    new Account("手机",2300)
};
decimal amount = Algorithms.Accumulate<Account, decimal>(
     accounts,  //传入的参数1
     (item, sum) => sum += item.Balance //传入的参数2
     );

本文后续会随着知识的积累不断补充和更新,内容如有错误,欢迎指正。
最后一次更新时间:2018-06-28

参考资源:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容