第8章:委托、Lambda表达式和事件

  • #1. 委托
    • 1.1 声明委托
    • 1.2 使用委托
    • 1.3 简单委托示例
    • 1.4 Action<T>和Func<T>委托
    • 1.5 BubbleSorter示例
    • 1.6 多播委托
    • 1.7 匿名方法
  • #2. Lambda 表达式
    • 2.1 参数
    • 2.2 多行代码
    • 2.3 Lambda表达式外部变量
  • #3. 事件
    • 3.1 事件发布程序
    • 3.2 事件侦听器
    • 3.3 弱事件

#1. 委托

当要把方法传递给其他方法时,需要使用委托。把方法的细节封装在一种新类型的对象中,即委托。委托只是一种特殊类型的对象,其特殊之处在于,我们以前定义的所有对象都包含数据,而委托包含的只是一个或多个方法的地址。

1.1 声明委托

在C#中使用一个类时,分两个阶段。首先,需要定义这个类,即告诉编译器这个类由什么字段和方法组成。然后(除非只使用静态方法),实例化类的一个对象。使用委托时,也需要经过这两个步骤。

  1. 首先必须定义要使用的委托,对于委托,定义它就是告诉编译器这种类型的委托表示哪种类型的方法。
  2. 然后,必须创建该委托的一个或多个实例。编译器在后台将创建表示该委托的一个类。
    定义委托的语法如下:
delegate void IntMethodInvoker(int x);

在定义委托时,必须给出它所表示的方法的签名和返回类型等全部细节。

==理解委托的一种好方式是把委托当作这样一件事情,它给方法的签名和返回类型指定名称。==

1.2 使用委托

下面的代码说明了如何使用委托。

private delegate string GetAString();

static void Main(string[] args)
{
    int x = 40;
    GetAString firstStringMethod = new GetAString(x.ToString);
    Console.WriteLine("String is {0}", firstStringMethod());
    //With firstStringMethod initialized to x.ToString(),
    //the above statement is equivalent to saying
    //Console.WriteLine("String is {0}",x.ToString());
}

在这段代码中,实例化了类型GetAString的一个委托,并对它进行初始化,使它引用整形变量x的ToString()。在C#中,委托在语法上总是接受一个参数的构造函数,这个参数就是委托引用的方法。这个方法必须匹配最初定义委托时的签名。所以在这个示例中,如果不带参数并返回一个字符串的方法来初始化firstStringMethod变量,就会产生一个编译错误。注意,因为int.ToString()是一个实例方法(不是静态方法),所以需要指定实例(x)和方法名来正确地初始化委托。

实际上,给委托实例提供圆括号与调用委托类的Invoke()方法完全相同。因为firstStringMethod是委托类型的一个变量,所以c#编译器会用firstStringMethod.Invoke()代替firstStringMethod()。

firstStringMethod();
firstStringMethod.Invoke();

为了减少输入量,只要需要委托实例,就可以只传送地址的名称。这称为委托推断。只要编译器可以把委托实例解析为特定的类型,这个C#特性就是有效的。下面用GetAString委托的一个新实例初始化GetAString类型的firstStringMethod变量:

GetAString firstStringMethod = new GetAString(x.ToString);

只要用变量x把方法名传送给变量firstStringMethod,就可以编写出作用相同的代码:

GetAString firstStringMethod = x.ToString;

==调用上述方法时输入形式不能为x.ToString()(不要输入圆括号),也不能把它传送给委托变量,输入圆括号调用一个方法,调用x.ToString()方法会返回一个不能赋予委托变量的字符串对象,只能把方法的地址赋予委托变量。==

委托推断可以在需要委托实例的任何地方使用。委托推断也可以用于事件,因为事件基于委托。

==给委托的实例可以引用任何类型的任何对象上的实例方法或静态方法——只要方法的签名匹配于类型的签名即可。==

1.3 简单的委托示例

在这个示例中,定义一个类MathsOperations,它有两个静态方法,对double类型的值执行两个操作,然后使用该委托调用这些方法。

class MathOperations
{
    public static double MultiplyByTwo(double value)
    {
        return value * 2;
    }

    public static double Square(double value)
    {
        return value * value;
    }
}

下面调用方法:

class DelegateTest
{
    private delegate double DoubleOp(double x);

    static void Main(string[] args)
    {
        DoubleOp[] operations = {
            MathOperations.MultiplyByTwo,
            MathOperations.Square
        };

        for (int i = 0; i < operations.Length; i++)
        {
            Console.WriteLine("Using operations[{0}]:", i);
            ProcessAndDisplayName(operations[i], 2.0);
            ProcessAndDisplayName(operations[i], 7.94);
            ProcessAndDisplayName(operations[i], 1.414);
            Console.WriteLine();
        }
    }

    static void ProcessAndDisplayName(DoubleOp action, double value)
    {
        double result = action(value);
        Console.WriteLine("Value is {0},result of operation is {1}", value, result);
    }
}

在这段代码中,实例化了一个委托数组DoubleOp(记住,一旦定义了委托类,基本上就可以实例化它的实例,就像处理一般的类那样——所以把一些委托的实例放在数组中是可以的)。该数组的每个元素都初始化为由MathsOperations类实现的不同操作。

1.4 Action<T>和Func<T>委托

除了为每个参数和返回类型定义一个新委托类型之外,还可以使用Action<T>和Func<T>委托。泛型Action<T>委托表示引用一个void返回类型的方法。

Func<double, double>[] operations = {
    MathOperations.MultiplyByTwo,
    MathOperations.Square
};
            
static void ProcessAndDisplayName(Func<double,double> action, double value)
{
    double result = action(value);
    Console.WriteLine("Value is {0},result of operation is {1}", value, result);
}

1.5 BubbleSorter示例

下面的示例将说明委托的真正用途。我们要编写一个类BubbleSorter,它实现一个静态方法Sort(),这个方法的第一个参数是一个对象数组,把该数组按照升序重新排列。

对于接受类型T的泛型方法Sort<T>,需要一个比较方法,其两个参数的类型是T,if比较的返回类型是布尔类型。这个方法可以从Func<T1,T2,TResult>委托中引用,其中T1和T2的类型相同:Func<T,bool>。给Sort<T>方法指定下述签名:

static public void Sort<T>(IList<T> sortArray,Func<T,T,bool> comparison);

这个方法的文档说明,comparison必须引用一个方法,该方法带有两个参数,如果第一个参数的值“小于”第二个参数,就返回true。

设置完毕后,下面定义BubbleSorter类:

class BubbleSorter
{
    static public void Sort<T>(IList<T> sortArray, Func<T, T, bool> comparison)
    {
        bool swapped = true;
        do
        {
            swapped = false;
            for (int i = 0; i < sortArray.Count - 1; i++)
            {
                if (comparison(sortArray[i + 1], sortArray[i]))
                {
                    T tmp = sortArray[i];
                    sortArray[i] = sortArray[i + 1];
                    sortArray[i + 1] = tmp;
                    swapped = true;
                }
            }
        } while (swapped);
    }
}

为了使用这个类,需要定义另一个类,从而建立要排序的数组。

class Employee
{
    public string Name { get; private set; }
    public decimal Salary { get; private set; }
    public Employee(string name, decimal salary)
    {
        this.Name = name;
        this.Salary = salary;
    }

    public override string ToString()
    {
        return string.Format("{0}, {1:C}", Name, Salary);
    }

    public static bool CompareSalary(Employee l, Employee r)
    {
        return l.Salary < r.Salary;
    }
}

注意,为了匹配Func<T,T,bool>委托的签名,在这个类中必须定义CompareSalary,它的参数是两个Employee引用,并返回一个布尔值,在实现比较代码中,根据薪水进行比较。

下面编写一些客户端代码,完成排序:

class DelegateTest
{
    static void Main(string[] args)
    {
         Employee[] employees = {
            new Employee("Bugs Bunny",20000),
            new Employee("Elmer Fudd",10000),
            new Employee("Daffy Duck",25000),
            new Employee("Will Coyote",1000000.38m),
            new Employee("Foghorn Leghorn",23000),
            new Employee("RoadRunner",50000),
        };
        BubbleSorter.Sort(employees, Employee.CompareSalary);

        foreach(var employee in employees)
        {
            Console.WriteLine(employee);
        }
    }
}

1.6 多播委托

前面使用的每个委托都只包含一个方法调用。调用委托的次数与调用方法的次数相同。如果要调用多个方法,就需要多次显式调用这个委托。但是,委托也可以包含多个方法。这种委托称为多播委托。如果调用多播委托,就可以按顺序连续调用多个方法。为此,委托的签名就必须返回void;否则,就只能得到委托调用的最后一个方法的结果。

可以使用返回类型为void的Action<double>委托:

class DelegateTest
{
    static void Main(string[] args)
    {
        Action<double> operations = MathOperations.MultiplyByTwo;
        operations += MathOperations.Square;
    }    
}

在前面的示例中,因为要存储对两个方法的引用,所以实例化了一个委托数组。而这里只是在同一个多播委托中添加两个操作。多播委托可以识别运算符"+"和"+="。

要委托引用返回void的方法,就应重写MathOperations类中的方法,从而让它们显示其结果,而不是返回它们:

class MathOperations
{
    public static void MultiplyByTwo(double value)
    {
        double result = value * 2;
        Console.WriteLine("Multiplying by 2: {0} gives {1}", value, result);
    }

    public static void Square(double value)
    {
        double result = value * value;
        Console.WriteLine("Squaring: {0} gives {1}", value, result);
    }
}

为了适应这个改变,也必须重写ProcessAndDisplayName()方法:

static void ProcessAndDisplayName(Action<double> action, double value)
{
    Console.WriteLine();
    Console.WriteLine("ProcessAndDisplayName called with value = {0}", value);
    action(value);
}

下面测试多播委托,其代码如下:

class DelegateTest
{
    static void Main(string[] args)
    {
        Action<double> operations = MathOperations.MultiplyByTwo;
        operations += MathOperations.Square;
        
        ProcessAndDisplayName(operations, 2.0);
        ProcessAndDisplayName(operations, 7.94);
        ProcessAndDisplayName(operations, 1.414);
        Console.WriteLine();    
    }    
}

如果正在使用多播委托,就应知道对同一个委托调用方法链的顺序并未正式定义。因此应避免编写依赖于以特定顺序调用方法的代码。

通过一个委托调用多个方法还可能导致一个大问题。多播委托包含一个逐个调用的委托集合。如果通过委托调用的其中一个方法抛出异常,整个迭代就会停止。

class DelegateTest
{
    static void Main(string[] args)
    {
        Action d1 = One;
        d1 += Two;

        try
        {
            d1();
        }
        catch (Exception e)
        {
            Console.WriteLine("Exception caught with msg:{0}",e.Message);
        }
    }
    
    static void One()
    {
        Console.WriteLine("One");
        throw new Exception("Error in one");
    }
    
    static void Two()
    {
        Console.WriteLine("Two");
    }
}

委托只调用了第一个方法。因为第一个方法抛出了异常,所以委托的迭代会停止,不再调用Two()。

在这种情况下,为了避免这个问题,应自己迭代方法列表。Delegate类定义GetInvocationList(),它返回一个Delegate数组。现在可以使用这个委托调用与委托直接相关的方法,捕获异常,并继续下一次迭代。

class DelegateTest
{
    static void Main(string[] args)
    {
        Action d1 = One;
        d1 += Two;

        Delegate[]  delegates = d1.GetInvocationList();
        foreach(Action d in delegates)
        {
            try
            {
                d();
            }
            catch (Exception e)
            {
                Console.WriteLine("Exception caught with msg:{0}", e.Message);
            }
        }
    }
}

1.7 匿名方法

到目前为止,要想使委托工作,方法必须已经存在(即委托是用它将调用的方法的相同的签名定义的)。但还有另外一种使用委托的方式:即通过匿名方法。匿名方法是用作委托的参数的一段代码。
用匿名方法定义委托的语法与前面的定义并没有区别。但在实例化委托时,就有区别了。

class DelegateTest
{
    static void Main(string[] args)
    {
        string mid = ", middle part,";
        Func<string, string> anonDel = delegate (string param)
        {
            param += mid;
            param += " and this was added to the string.";
            return param;
        };
        Console.WriteLine(anonDel("Start of string"));
    }
}

Func<string,string>委托接受一个字符串参数,返回一个字符串。anonDel是这种委托类型的变量。不是把方法名赋予这个变量,而是使用一段简单的代码:它前面是关键字delegate,后面是一个字符串参数。

在使用匿名方法时,必须遵循两条规则。在匿名方法中不能使用跳转语句(break、goto或continue)跳到该匿名方法的外部,反之亦然:匿名方法外部的跳转语句不能跳到该匿名方法的内部。

在匿名方法内部不能访问不安全的代码。另外,也不能访问在匿名方法外部使用的ref或out参数。但可以使用在匿名外部定义的其他变量。


#2. Lambda表达式

自C#3.0开始,就可以使用一种新语法把实现代码赋予委托:Lambda表达式。只要有委托参数类型的地方,就可以使用Lambda表达式。前面使用匿名方法的例子可以改为使用Lambda表达式:

class LambdaTest
{
    static void Main(string[] args)
    {
        string mid = ", middle part,";
        Func<string, string> lambda = param => 
        {
            param += mid;
            param += " and this was added to the string.";
            return param;
        };
        Console.WriteLine(lambda("Start of string"));
    }
}

Lambda运算符"=>"的左边列出了需要的参数。Lambda运算符的右边定义了赋予lambda变量的方法的实现代码。

2.1 参数

Lambda表达式有几种定义参数的方式。如果只有一个参数,只写出参数名字就足够了。下面的Lambda表达式使用了参数s。因为委托类型定义了一个string参数,所以s的类型就是string。

Func<string,string> oneParam = s => String.Format(
    "change uppercase {0}",s.ToUpper());
Console.WriteLine(oneParam("test"));

如果委托使用多个参数,就把参数名放在花括号中。这里参数x和y的类型是double,由Func<double,double,double>委托定义:

Func<double,double,double> twoParams = (x,y) => x * y;
Console.WriteLine(twoParams(3,2));

为了方便,可以在花括号中给变量名添加参数类型:

Func<double, double, double> twoParamsWithTypes = (double x, double y) => x * y;
Console.WriteLine(twoParamsWithTypes(4, 2));

2.2 多行代码

如果Lambda表达式只有一条语句,在方法块内就不需要花括号和return语句,因为编译器会添加一条隐式的return语句。

Func<double,double> square = x => x * x;

添加花括号、return语句和分号是完全合法的,通常这比不添加这些符号更容易阅读:

Func<double,double> square = x => {
    return x * x;
}

但是,如果在Lambda表达式的实现代码中需要多条语句,就必须添加花括号和return语句:

Func<string,string> lambda = param => {
    param += mid;
    param += " and this was added to the string";
    return param;
}

2.3 Lambda表达式外部的变量

通过Lambda表达式可以访问Lambda表达式块外部的变量。这是一个非常好的功能,但如果为正确使用,也会非常危险。

在下面的示例中,Func<int,int>类型的Lambda表达式需要一个int参数,返回一个int。该Lambda表达式的参数用变量x定义。实现代码还访问了Lambda表达式外部的变量someVal。只要不认为在调用f时,Lambda表达式创建了一个以后使用的新方法,这似乎没有什么问题。

int someVal = 5;
Func<int, int> f = x => x + someVal;
Console.WriteLine(f(10));

对于Lambda表达式x => x + someVal,编译器会创建一个匿名类,它有一个构造函数来传递外部变量。该构造函数取决于从外部传递进来的变量个数。

public class AnonymousClass 
{
    private int someVal;
    public AnonymousClass(int someVal) 
    {
        this.someVal = someVal;
    }
    
    public int AnonymouseMethod(int x) {
        return x + someVal;
    }
}

使用Lambda表达式并调用该方法,会创建匿名类的一个实例,并传递调用该方法时变量的值。

==Lambda表达式可以用于类型是一个委托的任意地方。类型是Expression或Expression<T>时,也可以使用Lambda表达式。此时编译器会创建一个表达式树。==


#3. 事件

事件基于委托,为委托提供了一种发布/订阅机制。

3.1 事件发布程序

从CarDealer类开始,它基于事件提供一个订阅。CarDealer类用event关键字定义了类型为EventHandler<CarInfoEventArgs>的NewCarInfo事件。在NewCar()方法中,触发NewCarInfo事件:

public class CarIInfoEventArgs : EventArgs
{
    public string Car { get; private set; }
    public CarIInfoEventArgs(string car)
    {
        this.Car = car;
    }
}

public class CarDealer
{
    public event EventHandler<CarIInfoEventArgs> NewCarInfo;

    public void NewCar(string car)
    {
        Console.WriteLine("CarDealer, new car {0}", car);
        NewCarInfo?.Invoke(this, new CarIInfoEventArgs(car));
    }
}

CarDealer类提供了EventHandler<CarIInfoEventArgs>类型的NewCarInfo事件。作为一个约定,事件一般使用带两个参数的方法,其中第一个参数是一个对象,包含事件的发送者,第二个参数提供了事件的相关信息。

委托EventHandler<TEventArgs>的定义如下:

public delegate void EventHandler<TEventArgs>(object sender,TEventArgs e)
    where TEventArgs : EventArgs

3.2 事件侦听器

Consumer类用作事件侦听器。这个类订阅了CarDealer类的事件,并定义了NewCarIsHere方法,该方法满足EventHandler<CarInfoEventArgs>委托的要求,其参数类型是object和CarInfoEventArgs:

public class Consumer
{
    private string name;

    public Consumer(string name)
    {
        this.name = name;
    }

    public void NewCarIsHere(object sender, CarIInfoEventArgs e)
    {
        Console.WriteLine("{0}: car {1} is new", name, e.Car);
    }
}

现在需要连接事件发布程序和订阅器。为此使用CarDealer类的NewCarInfo事件,通过“+=”创建一个订阅。消费者订阅了事件,接着消费者也订阅了事件,然后通过“-=”取消了订阅。

static void Main(string[] args)
{
    ar dealer = new CarDealer();

    var michael = new Consumer("Michael");
    dealer.NewCarInfo += michael.NewCarIsHere;

    dealer.NewCar("Mercedes");

    var nick = new Consumer("Nick");
    dealer.NewCarInfo += nick.NewCarIsHere;

    dealer.NewCar("Ferrari");

    dealer.NewCarInfo -= michael.NewCarIsHere;
    dealer.NewCar("Toyota");
}

3.3 弱事件

通过事件,直接连接到发布程序和侦听器。但垃圾回收有一个问题。例如,如果侦听器不再直接饮用,发布程序就仍有一个引用。垃圾回收器不能清空侦听器占用的内存,因为发布程序仍保有一个引用,会针对侦听器触发事件。

这种强连接可以通过弱事件模式来解决,即使用WeekEventManager作为发布程序和侦听器之间的中介。

1. 弱事件管理器

要使用弱事件,需要创建一个派生自WeekEventManager类的类。WeekEventManager类

 public class WeakCarInfoEventManager : WeakEventManager
{
    public static WeakCarInfoEventManager CurrentManager
    {
        get
        {
            WeakCarInfoEventManager manager = GetCurrentManager(typeof(WeakCarInfoEventManager))
                        as WeakCarInfoEventManager;
            if (manager == null)
            {
                manager = new WeakCarInfoEventManager();
                SetCurrentManager(typeof(WeakCarInfoEventManager), manager);
            }
            return manager;
        }
    }

    public static void AddListener(object source, IWeakEventListener listener)
    {
        CurrentManager.ProtectedAddListener(source, listener);
    }

    protected override void StartListening(object source)
    {
        (source as CarDealer).NewCarInfo += CarDealer_NewCarInfo;
    }

    void CarDealer_NewCarInfo(object sender, CarIInfoEventArgs e)
    {
       DeliverEvent(sender, e);
    }

    protected override void StopListening(object source)
    {
        (source as CarDealer).NewCarInfo -= CarDealer_NewCarInfo;
    }
}

对于发布程序类CarDealer,不需要做任何修改,其实现代码与前面相同。

2. 事件侦听器

侦听器需要改为实现IWeekEventListener接口。这个接口定义了ReceiveWeekEvent(),触发事件时,从弱事件管理器中调用这个方法。在该方法的实现代码中,应从触发的事件中调用NewCarIsHere()方法。

public class Consumer : IWeakEventListener
{
    private string name;

    public Consumer(string name)
    {
        this.name = name;
    }

    public void NewCarIsHere(object sender, CarIInfoEventArgs e)
    {
        Console.WriteLine("{0}: car {1} is new", name, e.Car);
    }

    public bool ReceiveWeakEvent(Type managerType, object sender, EventArgs e)
    {
        NewCarIsHere(sender, e as CarIInfoEventArgs);
        return true;
    }
}

在Main方法中,连接发布程序和侦听器,该连接现在使用WeekCarInfoEventManager类的AddListener()和RemoveListener()静态方法。

static void Main(string[] args)
{
    var dealer = new CarDealer();

    var michael = new Consumer("Michael");
    WeakCarInfoEventManager.AddListener(dealer, michael);

    dealer.NewCar("Mercedes");

    var nick = new Consumer("Nick");
    WeakCarInfoEventManager.AddListener(dealer, nick);

    dealer.NewCar("Ferrari");

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

推荐阅读更多精彩内容

  • 前言 人生苦多,快来 Kotlin ,快速学习Kotlin! 什么是Kotlin? Kotlin 是种静态类型编程...
    任半生嚣狂阅读 26,133评论 9 118
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,081评论 1 32
  • 写在开头:本人打算开始写一个Kotlin系列的教程,一是使自己记忆和理解的更加深刻,二是可以分享给同样想学习Kot...
    胡奚冰阅读 1,222评论 0 6
  • 从爬虫必要的几个基本需求来讲: 1.抓取 py的urllib不一定去用,但是要学,如果还没用过的话。 比较好的替代...
    Python程序媛阅读 525评论 0 7
  • 梦里以为花间月 醒来原是满地霜
    老去的牛粪阅读 168评论 0 1