Take you through the new features in each version of C #

Learning at school C # and .NET, then online resource not been so rich, so go to Computer City to buy a pirated CD's of VS2005, when the installation is found VS2003, there was a feeling of being pit, but it is so, let me have a full .NET learning career.

Always think that learning a language should be learning system, to understand each version of the new features can be targeted in practical applications. Recently I discovered a lot of people though the team with the latest technology, but knowledge reserves still remain in a relatively initial state, so that the encoding process will take a lot of detours.

This paper reviews the C # under some common characteristics from version 1.0 to 7.0, for unusual or that I had not used some of the features will be listed, but will not do in detail. In addition C # 8.0 not yet officially launched, and at present we are only in use dotNet Core2.1, so C # 8.0 this article will not be involved.

C#1.X

C# VS version CLR version .NET Framework
1.0 VS2002 1.0 1.0
1.1 VS2003 1.1 1.1

In C # 1.0 or 1.1 version, from the point of view of language is the basic object-oriented syntax, you can say that any one of the C # language C # books contain all the contents of the 1.X.

If you are already writing code in C # language, C # 1.X related knowledge should have mastered. Basic grammar section will not repeat them here.

C#2.0

C# VS version CLR version .NET Framework
2.0 VS2005 2.0 2.0

2.0 corresponds to VS2005 I used not much, because soon replaced VS2008, but in the language they brought a lot of new things.

Generics

The most important characteristic is the C # 2 should be generic. Generic use is in some scenes can reduce the cast to improve performance. In C # 1, a lot of the cast, especially for some time to traverse the collection, such as ArrayList, HashTable, because they are designed for a different set of data types, so they are the key and type values ​​are object, this it means that the operation will be boxing and unboxing of the ordinary occurred. With generics in C # 2, so we can use the List, Dictionary. Generics can bring good compile-time type checking, there will be no boxing operation unpacking, because the type is in the use of generics has been specified.

.NET has been through a lot of generic types for our use, as mentioned above, List, Dictionary, we can also create your own generic types (classes, interfaces, delegates, structures) or method. It can be defined at the generic type or generic constraints by defining restrictions of generic parameters, better use compile time checking. Generic constraints is achieved by keywords where, C # 2 in generic constraints there are four kinds:

  • Reference type constraint: ensuring argument type is a reference type, using where T: class represented;

  • Value type constraint: ensuring argument type is a value type, using where T: truct represented;

  • Constructor type constraint, using where T: new () is represented;

  • Conversion type constraints: Constraint type argument is another type, for example: where T: IDisposable.

Partial class (Partil)

Partial class allows us to write code for a type (class, struct, interface) in multiple files, with a very wide range in Asp.Net2.0. Create a Aspx page, define and control CodeBehind page by page in the partial class is implemented. as follows:

public partial class _Default : System.Web.UI.Page 
public partial class _Default 

Use the keyword partial partial class is defined as a class code is very long, you can use partial classes to split, this reading of the code is very good, and will not affect the calls. But now we separate the front and rear end, back-end code to do a single responsibility principle, there will be a lot of big class, so this feature is rarely used.

Static class

Public static class method must also be static and can be called directly by the class name, you do not need to instantiate, more suitable for the preparation of a number of tools. As System.Math class is a static class. Tools Some features, such as: all members are static and do not need to be inherited, do not need to be instantiated. In C # 1 we can be done by the following code:

// sealed class declared as being inherited prevent 
public class Sealed The StringHelper 
{ 
    // add the number of private constructor with no arguments is instantiated ˉ prevented, without adding private constructors 
    // will automatically generate a total no-argument constructor 
    private StringHelper () { }; 
    public static int StringToInt32 (String INPUT) 
    { 
        int Result = 0; 
        Int32.TryParse (INPUT, OUT Result); 
        return Result; 
    } 
}

C # 2 can be used to achieve a static class:

public static class StringHelper
{
    public static int StringToInt32(string input)
    {
        int result=0;
        Int32.TryParse(input, out result);
        return result;
    }
}

Property access levels

In C # 1 declared property, access levels get and set properties and attributes are the same, either both are public or private, if you want to achieve and get set different levels of access, you need to use a workaround of way, and write their own GetXXX SetXXX method. In C # 2 may be provided in the individual access levels get and set, as follows:

private string _name;
public string Name
{
    get { return _name; }
    private set { _name = value; }
}

It should be noted, can not speak to private property, but will get or set which is set to public, set and get set does not give the same level of access, as set access levels and get the same, we can disposed directly on the property.

Namespace alias

Namespace class can be used to organize, when different namespaces have the same class, it can be used to prevent conflicts fully qualified name of the class name, C # 1 in the space can be used to simplify writing alias, alias space by using keywords achieve. But there are some special cases, using not completely resolved, so C # 2 are provided in the following several characteristics:

  • Namespace qualifier syntax

  • Global namespace alias

  • External Alias

When we build namespaces and classes, try to avoid conflict, this feature is also less frequently used.

Friend Assemblies

When we want to focus on a type of program can be outside of  some  assemblies to access, then if set to Public, can be accessed by all external assemblies. How to access only certain assemblies, it is necessary to use Friend Assemblies, and Bowen "C # before specific reference: Friend Assemblies (http://blog.fwhyy.com/2010/11/csharp-a-friend- assembly /) "

Nullable type

Nullable type is allowed value types is null. Usually the type of value should not be null, but many of us deal with applications and databases, and database types are may be null value, which resulted when we write programs sometimes need to set the value type It is null. Is typically used to handle this situation the "magic values" in C # 1, such DateTiem.MinValue, Int32.MinValue. In ADO.NET null values ​​of all types can be represented by DBNull.Value. C # 2 may be empty main types of generic type used System.Nullable type parameter T has the value type constraint. Such may be defined nullable types as follows:

Nullable<int> i = 20;
Nullable<bool> b = true;

C # 2 also provides a more convenient way to define, using the operator?:

int? i = 20;
bool? b = true;

Iterator

In C # 2 iterators provides a more convenient implementation. Iterator mentioned, there are two concepts you need to know

  • Enumerable objects and enumerators, System.Collections.IEnumerable object interface implements are enumerable objects, which can be performed in the foreach C # iteration;

  • Implements System.Collections.IEnumeror interface objects are called enumerators. Implement iterators in C # 1 is very complicated,

A look at the following example:

public class Test 
{
    static void Main()
    {
        Person arrPerson = new Person("oec2003","oec2004","oec2005");
        foreach (string p in arrPerson)
        {
            Console.WriteLine(p);
        }
        Console.ReadLine();
    }
}
public class Person:IEnumerable 
{
    public Person(params string[] names)
    {
        _names = new string[names.Length];
        names.CopyTo(_names, 0);
    }
    public string[] _names;
    public IEnumerator GetEnumerator()
    {
        return new PersonEnumerator(this);
    }
    private string this[int index]
    {
        get { return _names[index]; }
        set { _names[index] = value; }
    }
}
public class PersonEnumerator : IEnumerator 
{
    private int _index = -1;
    private Person _p;
    public PersonEnumerator(Person p) { _p = p; }
    public object Current
    {
        get { return _p._names[_index]; }
    }
    public bool MoveNext()
    {
        _index++;
        return _index < _p._names.Length;
    }
    public void Reset()
    {
        _index = -1;
    }
}

C # 2 iterators become very convenient, use the keyword yield return keyword implementation, the following C # 2 is used in yield return rewritten version:

public class Test 
{
    static void Main()
    {
        Person arrPerson = new Person("oec2003","oec2004","oec2005");
        foreach (string p in arrPerson)
        {
            Console.WriteLine(p);
        }
        Console.ReadLine();
    }
}
public class Person:IEnumerable 
{
    public Person(params string[] names)
    {
        _names = new string[names.Length];
        names.CopyTo(_names, 0);
    }
    public string[] _names;
    public IEnumerator GetEnumerator()
    {
        foreach (string s in _names)
        {
            yield return s;
        }
    }
}

Anonymous Methods

Anonymous method is more suitable for the method defined by the delegate must be invoked by multiple threads For example, the following code C # 1:

private void btnTest_Click(object sender, EventArgs e)
{
    Thread thread = new Thread(new ThreadStart(DoWork));
    thread.Start();
}
private void DoWork()
{
    for (int i = 0; i < 100; i++)
    {
        Thread.Sleep(100);
        this.Invoke(new Action<string>(this.ChangeLabel),i.ToString());
    }
}
private void ChangeLabel(string i)
{
    label1.Text = i + "/100";
}

C # 2 using the anonymous method, the above example may be omitted and ChangeLabel DoWork two methods, as follows:

private void btnTest_Click(object sender, EventArgs e)
{
    Thread thread = new Thread(new ThreadStart(delegate() {
        for (int i = 0; i < 100; i++)
        {
            Thread.Sleep(100);
            this.Invoke(new Action(delegate() { label1.Text = i + "/100"; }));
        }
    }));
    thread.Start();
}

Other relevant characteristics

  • Fixed size buffer (Fixed-size buffers)

  • Compiler directive (Pragma directives)

  • Commissioned by the inverter covariant

C#3.0

C# VS version CLR version .NET Framework
3.0 VS2008 2.0 3.0 3.5

If the core C # 2 is generic, then 3 should be the core C # Linq, and the characteristics of the C # 3 are almost as Linq services, but each feature can come out Linq use. Here we look at C # 3 which has characteristics.

Auto-implemented properties

This feature is very simple, is to make it easier to define the attributes. code show as below:

public string Name { get; set; }
public int Age { private set; get; }

Local variables and types of implicit extension method

Local variable type is implicit definition of variables we can compare the dynamic var keyword as a type of placeholder and type of variable is derived by the compiler.

The method can be extended to add some custom method on an existing type, for example, you can add an extension method on string type ToInt32, you can be like "20" .ToInt32 () so called.

Referring specifically to "C # 3.0 learning (1) - and the implicit local variable type extension method (http://blog.fwhyy.com/2008/02/learning-csharp-3-0-1-implied-type-of- local-variables-and-extension-methods /) ".

Implicit type coding though so convenient, but there are many restrictions:

  • The only variable is declared local variable, and can not be static variables and instance fields;

  • Variables must be initialized in a statement that the initialization value can not be null;

  • Statements only declare a variable;

Object collection initializers

Simplify and create a collection of objects, details, see "C # 3.0 learning (2) - a collection of objects initializers (http://blog.fwhyy.com/2008/02/learning-c-3-0-2-object- collection-initializer /) ".

Implicit array of type

And similar types of implicit local variables, you can not display the specified type defined array, usually we define the array like this:

string[] names = { "oec2003", "oec2004", "oec2005" };

Use the following anonymous typed arrays can think of this definition:

protected void Page_Load(object sender, EventArgs e)
{
    GetName(new[] { "oec2003", "oec2004", "oec2005" });
}
public string GetName(string[] names)
{
    return names[0];
}

Anonymous types

Anonymous types are initialized automatically generated when a mechanism based on the type of initialization list, using the object initializer to create an anonymous object objects details, see "C # 3.0 learning (3) - Anonymous types (http: //blog.fwhyy .com / 2008/03 / learning-csharp-3-0-3-anonymous-types /) ".

Lambda expressions

It is actually an anonymous method is a Lambda expression forms :( parameter list) => {statement}, look at an example, to create a delegate instance of type string obtaining a string and returns the length of the string. code show as below:

Func<string, int> func = delegate(string s) { return s.Length; };
Console.WriteLine(func("oec2003"));

Lambda use the wording as follows:

Func<string, int> func = (string s)=> { return s.Length; };
Func<string, int> func1 = (s) => { return s.Length; };
Func<string, int> func2 = s => s.Length;

The above three wording is gradually simplified the process.

Lambda expression tree

Is an expression .NET3.5 proposed, some of the code provides an abstract way as an object tree. Lambda expressions need to use a reference namespace System.Linq.Expressions tree, the code following expression tree build a 2 + 1, the final expression tree to get compiled into execution result delegate:

Expression a = Expression.Constant(1);
Expression b = Expression.Constant(2);
Expression add = Expression.Add(a, b);
Console.WriteLine(add); //(1+2) Func<int> fAdd = Expression.Lambda<Func<int>>(add).Compile();
Console.WriteLine(fAdd()); //3 

Lambda and Lambda expressions Linq using tree provides us a lot of support, if we are doing a management system using Linq To Sql, according to multiple criteria to filter the data in the function list page will have, then you can using a Lambda expression trees encapsulated query, the following class encapsulates and and Or two conditions:

public static class DynamicLinqExpressions 
{
    public static Expression<Func<T, bool>> True<T>() { return f => true; }
    public static Expression<Func<T, bool>> False<T>() { return f => false; }

    public static Expression<Func<T, bool>> Or<T>(this Expression<Func<T, bool>> expr1,
                                                        Expression<Func<T, bool>> expr2)
    {
        var invokedExpr = Expression.Invoke(expr2, expr1.Parameters.Cast<Expression>());
        return Expression.Lambda<Func<T, bool>>
              (Expression.Or(expr1.Body, invokedExpr), expr1.Parameters);
    }

    public static Expression<Func<T, bool>> And<T>(this Expression<Func<T, bool>> expr1,
                                                         Expression<Func<T, bool>> expr2)
    {
        var invokedExpr = Expression.Invoke(expr2, expr1.Parameters.Cast<Expression>());
        return Expression.Lambda<Func<T, bool>>
              (Expression.And(expr1.Body, invokedExpr), expr1.Parameters);
    }
}

Here's how to get conditions:

public Expression<Func<Courses, bool>> GetCondition()
{
    var exp = DynamicLinqExpressions.True<Courses>();
    if (txtCourseName.Text.Trim().Length > 0)
    {
        exp = exp.And(g => g.CourseName.Contains(txtCourseName.Text.Trim()));
    }
    if (ddlGrade.SelectedValue != "-1")
    {
        exp=exp.And(g => g.GradeID.Equals(ddlGrade.SelectedValue));
    }
    return exp;
}

Linq

Linq is a big topic, but also NET3.5 in core content is, there are many books dedicated to introduce Linq, following just do some simple introduction should be noted that Linq is not Linq To Sql, Linq is a big collection, which contains:

  • Linq To Object: providing process and the collection of objects;

  • Linq To XML: used in XML;

  • Linq To Sql: applied SqlServer database;

  • Linq To DataSet: DataSet;

  • Linq To Entities: used in relational databases other than SqlServer, we can achieve more data sources supported by Linq extension Linq framework.

Below Linq To Object as an example to look at is how to use Linq:

public class UserInfo 
{
    public string Name { get; set; }
    public int Age { get; set; }
}
public class Test 
{
    static void Main()
    {
        List<UserInfo> users = new List<UserInfo>()
        {
            new UserInfo{Name="oec2003",Age=20},
            new UserInfo{Name="oec2004",Age=21},
            new UserInfo{Name="oec2005",Age=22}
        };
        IEnumerable<UserInfo> selectedUser = from user in users
                                             where user.Age > 20
                                             orderby user.Age descending select user;
        foreach (UserInfo user in selectedUser)
        {
            Console.WriteLine("姓名:"+user.Name+",年龄:"+user.Age);
        }
        Console.ReadLine();
    }
}

As can be seen, Linq allows us to use the keyword similar Sql to query the collection, objects, XML and so on.

C#4.0

C# VS version CLR version .NET Framework
4.0 VS2010 4.0 4.0

Optional parameters

VB has been in the very early support optional parameters, and know C # 4 and we have to support, by definition, is an optional parameter some parameters can be optional, you can not enter when the method is called. Look at the code below:

public class Test 
{
    static void Main()
    {
        Console.WriteLine(GetUserInfo()); //姓名:ooec2003,年龄:30 
        Console.WriteLine(GetUserInfo("oec2004", 20));//姓名:ooec2004,年龄:20 
        Console.ReadLine();
    }
    public static string GetUserInfo(string name = "oec2003", int age = 30)
    {
        return "姓名:" + name + ",年龄:" + age.ToString();
    }
}

Named arguments

Is named argument value in the development of argument can specify the name of the corresponding parameter at the same time. The compiler can determine the correct name of the parameter, named arguments allows us to change the order of the parameters when calling. Also named argument often used with optional parameters, see the following code:

static void Main()
{
    Console.WriteLine(Cal());//9 
    Console.WriteLine(Cal(z: 5, y: 4));//25 
    Console.ReadLine();
}
public static int Cal(int x=1, int y=2, int z=3)
{
    return (x + y) * z;
}

Optional and named by the combination of parameters, we can override the code reduction method.

Dynamic type

C # uses dynamic to achieve dynamic typing, where useless use of dynamic, C # remains static. Static type when we want to use the program focused on the classes, methods, classes to be invoked, the compiler must know the assembly have this class, this class has a method, if you can not know in advance, would compile-time error in C # 4 used to be able to solve this problem through reflection. Look at a small example of using dynamic:

A = Dynamic "oec2003"; 
Console.WriteLine (A.length); //. 7 
Console.WriteLine (a.length); // String length does not include the type of property, the compiler does not complain runtime error will 
Console.ReadLine ();

You may find the use of dynamic and variable declarations var C # 3 provided in the somewhat similar, others they are essentially different variables var declared at compile time to infer the actual type, var is equivalent to just a placeholder , while dynamic variables declared at compile-time type checking will not.

use more dynamic should be the alternative to the previous reflection, and the performance has greatly improved. Suppose there is a single assembly called DynamicLib DynamicClassDemo a class, a class Cal method, using reflection to see how access Cal following methods:

namespace DynamicLib
{
    public class DynamicClassDemo 
    {
        public int Cal(int x = 1, int y = 2, int z = 3)
        {
            return (x + y) * z;
        }
    }
}
static void Main()
{
    Assembly assembly = Assembly.Load("DynamicLib");
    object obj = assembly.CreateInstance("DynamicLib.DynamicClassDemo");
    Type type = obj.GetType();
    MethodInfo method = type.GetMethod("Cal");
    Console.WriteLine(method.Invoke(obj, new object[] { 1, 2, 3 }));//9 
    Console.ReadLine();
}

A dynamic code is as follows:

Assembly assembly = Assembly.Load("DynamicLib");
dynamic obj = assembly.CreateInstance("DynamicLib.DynamicClassDemo");
Console.WriteLine(obj.Cal());
Console.ReadLine();

The rear end of the first mode of separation, the WebAPI interface parameters may be used to define the dynamic, the front end can be resolved directly json incoming parameters, not every interface method defines a parameter type. Bad part was when the API documentation is produced by Swagger, you can not clearly know the meaning of the input parameters of each property.

There are some C # 4 COM interoperability improvements and inverter and covariance of improvement, I almost did not use, so this is not told.

C#5.0

C# VS version CLR version .NET Framework
5.0 VS2012\2013 4.0 4.5

Asynchronous Processing

Asynchronous processing is very important in a characteristic C # 5, involves two keywords: async and await, talk understand the need to write a separate one to introduce.

Can be simply understood as a form Winform program when there is a time-consuming operation, if it is synchronous operation, the form will get stuck before returning the result, of course, there are ways to solve this in previous versions of C # 5 in problem, but C # 5 of asynchronous processing solutions are more elegant.

Loop variable capture

Not so much a feature as it is to fix the problem before the release, see the following code:

public static void CapturingVariables()
{
    string[] names = { "oec2003","oec2004","oec2005"};
    var actions = new List<Action>();

    foreach(var name in names)
    {
        actions.Add(() => Console.WriteLine(name));
    }
    foreach(Action action in actions)
    {
        action();
    }
}

This code before the C # version, the continuous output will be three oec2005, in C # 5 will be sequentially output oec2003, oec2004, oec2005 in accordance with our expectations.

If your code is to use the results of this error, then upgrading to C # 5 or above in the previous versions need to be aware.

The caller information characteristics

Our program usually release form of release, after the release of specific information is difficult to trace code execution, offers three properties (Attribute) in C # 5, allowing the execution file name to obtain the caller's current compilers, where the number of rows with the method or property name. code show as below:

static void Main(string[] args)
{
    ShowInfo();

    Console.ReadLine();

}
public static void ShowInfo(
   [CallerFilePath] string file = null,
   [CallerLineNumber] int number = 0,
   [CallerMemberName] string name = null)
{
    Console.WriteLine($"filepath:{file}");
    Console.WriteLine($"rownumber:{number}");
    Console.WriteLine($"methodname:{name}");
}

Call the results are as follows:

filepath:/Users/ican_macbookpro/Projects/CsharpFeature/CsharpFeature5/Program.cs
rownumber:12
methodname:Main

C#6.0

C# VS version CLR version .NET Framework
6.0 VS2015 4.0 4.6

Provided a lot of new features in C # 6, I think the most useful is the Null conditional operators and string embedded.

Null conditional operator

In C #, a common abnormality is the "Object reference not to object instance" because the object is not to make a reference to determine the cause of non-empty. Although the team has repeatedly stressed, but will still fall on this issue. The following code will cause this error:

class Program
{
    static void Main(string[] args)
    {
        //Null条件运算符
        User user = null;
        Console.WriteLine(user.GetUserName());
        Console.ReadLine();
    }
}
class User
{
    public string GetUserName() => "oec2003";
}

To not make mistakes, you need to do is not empty judgment on user objects

if(user!=null)
{
    Console.WriteLine(user.GetUserName()); 
}

In C # 6 can be a very simple way to deal with this problem

// Null conditional operator 
the User User = null; 
Console.WriteLine (User .GetUserName ()?);

Note: Although this syntax is very simple sugar, as well, but also need to think about when using the step, when the object is empty, call the method returns a value that is empty, so the value of the subsequent action will not have the impact, if any, still need to make a judgment, and do the relevant treatment.

String embedded

String embedded string concatenation can be simplified, intuitive meaning of the expression may need to know, in C # 6 and above are to be treated in this manner string concatenation, as follows:

// string embedded 
string name = "oec2003"; 
before treatment // version. 1 
Console.WriteLine ( "the Hello" + name); 
previous version handling // 2 
Console.WriteLine (string.Format ( "the Hello { } 0 ", name)); 
// C # string embedding processing mode. 6 
Console.WriteLine ($" Hello {name} ");

Other relevant characteristics

  • Automatic read-only attribute

  • Automatic attribute initialization expression

  • using static

  • nameof expression

  • Exception filter

  • Using the associated set of initialization indexer

C#7.0

C# VS version .NET Framework
7.0 VS2017 15.0 .NET Core1.0
7.1 VS2017 15.3 .NET Core2.0
7.2 VS2017 15.5 .NET Core2.0
7.3 VS2017 15.7 .NET Core2.1

out variable

This feature simplifies the use of the variable out, the previous version using the following code:

int result = 0;
int.TryParse("20", out result);
Console.WriteLine(result);

Optimized code, does not require a pre-defined variable

int.TryParse("20", out var result);
Console.WriteLine(result);

Pattern Matching

It is also a syntactic sugar to reduce our coding directly looking at the code it

PatternMatching class public 
{ 
    public void the Test () 
    { 
        List <the Person> = new new List List <the Person> (); 
        list.add (new new Man ()); 
        list.add (new new Woman ()); 
        the foreach (var List Item in ) 
        { 
                 // This type judgment needs to be done before the conversion and the type of release 
            IF (Item IS man man) 
                Console.WriteLine (man.GetName ()); 
            the else IF (Item Woman Woman IS) 
                Console.WriteLine (woman.GetName ()); 
        } 
    } 
} 
public abstract class the Person 
{ 
    public abstract String GetName (); 
} 
public class Man: the Person 
{
    public override string GetName() => "Man";
}
public class Woman : Person
{
    public override string GetName() => "Woman";
}

Details refer to the official document: https: //docs.microsoft.com/zh-cn/dotnet/csharp/pattern-matching

Native Method

The method can be written in the internal method, may perform the same process in the plurality of code logic in the method, prior practice is to write private methods the class, so that now the method written in the private internal method to improve readable codes sex.

static void LocalMethod()
{
    string name = "oec2003";
    string name1 = "oec2004";

    Console.WriteLine(AddPrefix(name));
    Console.WriteLine(AddPrefix(name1));

    string AddPrefix(string n)
    {
        return $"Hello {n}";
    }
}

Asynchronous main method

The biggest benefit is that the console program debugging asynchronous method becomes very convenient.

static async Task Main()
{
    await SomeAsyncMethod();
}

private protected access modifier

May be limited to the same set of procedures to access the derived class, it is a reinforcement of protected internal, and protected internal refers to the same assembly class or derived class access.

Other relevant characteristics

  • Optimization tuple (7.0)

  • Abandoned yuan (7.0)

  • Ref local variables and return results (7.0)

  • Universal asynchronous return type (7.0)

  • Digital text syntax improvements (7.0)

  • throw expression (7.0)

  • The default text expression (7.1)

  • Inferred tuple element names (7.1)

  • Non-trailing named parameters (7.2)

  • Numerical leading underscore character in (7.2)

  • Conditions ref expression (7.2)

to sum up

Each feature we need to go under Coding, understand the true meaning and purpose, we can use the flexibility in their work.

Guess you like

Origin blog.csdn.net/Abelia/article/details/93773280