C # IEnumerator use

Original link: https://www.cnblogs.com/w-wfy/p/7418459.html

Iterator pattern is an example of design mode behavior (behavioral pattern), he is a simplified inter-object communication model, is also a very easy to understand and use patterns. In short, iterative mode enables you to get all the elements of a sequence without concern for its type array, list, linked list or any other sequence structure. This makes it possible to build a data processing channel (data pipeline) very efficient - i.e., data channels can enter the process, a series of transformation, or filtering, and the results obtained. In fact, this is the core LINQ pattern.

    In .NET, the iterative mode is encapsulated IEnumerable IEnumerator and their corresponding generic interface. If a class implements the IEnumerable interface, then it can be iterative; call GetEnumerator method returns implement IEnumerator interface, it is the iterator itself. Similar iterator database cursor, he is a location record sequence data. Iterator can only move forward, in the same data sequence there may be multiple iterations simultaneously operate on the data.

    In C # 1 has built-in support for iterators, that is foreach statement. Enabling more direct than the for loop and simple iteration, the compiler will compile a collection of foreach to call GetEnumerator and MoveNext method and the Current property, if the object implements IDisposable, will be released after the completion of iterations iterator. However, in C # 1, to achieve a relatively iterator is somewhat tedious operation. C # 2 so that the work has become much simpler, saving a lot of work to achieve the iterator.

Next, we look at how to implement an iterator and C # 2 iterators to simplify the implementation, and then cited several examples of iterators in real life.

 

1. C # 1: iterator implemented manually tedious

 

    Suppose we need to implement a new collection based on the type of ring buffer. We will implement the IEnumerable interface, enabling users to easily take advantage of all the elements of the collection. We ignore other details, attention is focused only on how to implement the iterator. The set value is stored in an array, a set of iteration start point can be provided, for example, assume a set of five elements, you can be the starting point is 2, then the output is 2,3,4,0 iterations, and finally 1.

    In order to be able to demonstrate simple, we offer a constructor and a value set the starting point. This allows us to in the following way through the collection:

object[] values = { "a", "b", "c", "d", "e" };
IterationSample collection = new IterationSample(values, 3);
foreach (object x in collection)
{
    Console.WriteLine(x);
}

Since we start to the 3, the result set is outputted d, e, a, b and C, now we look at how to achieve IterationSample iterator class:

class IterationSample : IEnumerable
{
    Object[] values;
    Int32 startingPoint;
    public IterationSample(Object[] values, Int32 startingPoint)
    {
        this.values = values;
        this.startingPoint = startingPoint;
    }
    public IEnumerator GetEnumerator()
    {
        throw new NotImplementedException();
    }
}

    We have not implemented GetEnumerator method, but how to write GetEnumerator part of the logic of it, the first is the existence of current state of the cursor in a certain place. On the one hand is the iterator pattern not return once all the data, but the client only once a data request. This means that we want to record the customer's current request to set a record of that. C # 2 compiler for state iterator saved us a lot of work to do.

       Now take a look at where in which to save the state as well as state, that we try to imagine the state of preservation in IterationSample collection, making it implements IEnumerator and IEnumerable method. At first sight, it seems likely, after all the data in the right place, including the starting position. Our GetEnumerator method simply returns this. However, this method has a very important question, if GetEnumerator method is called multiple times, multiple independent iterators will return. For example, we can use two nested foreach statements, to get all the possible value pairs. Both need each iteration independence. This means that two iterator object returned when we need to call every GetEnumerator must remain independent. We can still be achieved by the appropriate function directly in IterationSample class. But our class has a number of responsibilities, the back of the single responsibility principle.

     So, let's create another class that implements the iterator itself. We use inner classes in C # to achieve this logic. code show as below:

IterationSampleEnumerator class: the IEnumerator 
{ 
    IterationSample parent; // iteration object. 1 # 
    Int32 position; // current position of the cursor 2 # 
    Internal IterationSampleEnumerator (IterationSample parent) 
    { 
        this.parent = parent; 
        position = -1; // array element subscript starts from 0, the initial default current cursor is set to -1, i.e., before the first element, #. 3 
    } 

    public BOOL the MoveNext () 
    { 
        iF (position! = parent.values.Length) // determines whether the current position is the last a, if not the self-energizing cursor. 4 # 
        { 
            position ++; 
        } 
        return position <parent.values.Length; 
    } 

    public Object Current 
    { 
        GET 
        {
            if (position == -1 || position == parent.values.Length) // first and before the last access from the illegal. 5 # 
            { 
                the throw an InvalidOperationException new new (); 
            } 
            Int32 index = position + parent.startingPoint ; // consider the case of a customized start position. 6 # 
            index = index% parent.values.Length; 
            return parent.values [index]; 
        } 
    } 

    public void the reset () 
    { 
        position = -1; // reset the cursor -1 # 7 
    } 
}

To achieve a simple iterative code need to manually write so: the need to record a set of the original iteration # 1, # 2 record the current cursor position, the return element, the current position of the cursor and the start of the array defined in the given iterator # 6 position in the array. At initialization, the current position is set before the first element # 3, first call when the first call the MoveNext iterator, then call Current property. Conditional judgment # 4, so that even when there is no return of the first call to MoveNext element # 5 will not have an error during the self-energizing current cursor position. When you reset the iterator, we will revert to the current cursor position before the first element # 7.

    In addition to the starting position with the current cursor position and returns the correct custom error-prone this value, the above code is very intuitive. Now, just returns an iterator class can only write us when the GetEnumerator method IterationSample class:

public IEnumerator GetEnumerator()
{
    return new IterationSampleEnumerator(this);
}

    It is worth noting that the above example is a relatively simple, there is not much need to keep track of the state, without checking whether the collection changes occurred during iteration. In order to achieve a simple iterator, in C # 1 we have achieved so much code. When using the Framework comes with a collection of implements IEnumerable interface we use foreach is easy, but when we write our own set of iterations required to achieve write so compiled code.

    In C # 1, it takes about 40 lines of code to implement a simple iterator, C # 2 and now look to improve on this process.

 

2. C # 2: simplify the iterating through yield statement

 

2.1 an iteration block (Iterator) yield return statement and

C # 2 iterations makes it easier - reduce the amount of code also makes the code a lot more elegant. The following code shows the complete code C # 2 and then implemented GetEnumerator method:

public IEnumerator GetEnumerator()
{
    for (int index = 0; index < this.values.Length; index++)
    {
        yield return values[(index + startingPoint) % values.Length];
    }
}

A few simple lines of code will be able to fully realize the function IterationSampleIterator the required categories. The method looks very ordinary, except for using yield return. This statement tells the compiler that this is not an ordinary method, iterative block (yield block) but rather a need to perform, he returns a IEnumerator object that you can use to perform iterative block iterative method and the return type of a need to implement IEnumerable, IEnumerator corresponding or generic. If the version of the non-generic interface implemented, returning to block iteration yield type is of type Object, otherwise the corresponding generic type. For example, if implemented method IEnumerable <String> interfaces, then the type is of type String yield return. In addition to the block iteration yield return, the normal return statement not allowed. All yield return statement block must be returned and finally return type compatible with the type of block. For example, when the method needs to return IEnumeratble <String> type, we can not yield return 1. It should be emphasized that, for the iterative block, though we write in order to perform the method looks like, we actually let the compiler to create a state machine for us. --- This is the code to call that part of those in C # 1 we write each call only needs to return a value, so we need to remember the last time the return value, location in the collection. When the compiler encounters iteration block is that it creates a class implements an internal state machine. This class remember the exact current location, and iterator our local variables, including arguments. This class is somewhat similar to that code our handwriting before, he saved all states need to be recorded as instance variables. Let's look at, in order to achieve an iterator, the state machine needs to be performed in the order:

  • It requires some initial state
  • When MoveNext is called, he needs to execute code GetEnumerator method to prepare a next data to be returned.
  • When calling the Current property is required to return the value yielded.
  • You need to know when iteration End Yes, MoveNext returns false

Let's look at the order of execution iterator.

 

2.2 iterator's execution flow

The following code shows the flow of execution iterator code output (0,1,2, -1), and then terminates.

class Program
{
    static readonly String Padding = new String(' ', 30);
    static IEnumerable<Int32> CreateEnumerable()
    {
        Console.WriteLine("{0} CreateEnumerable()方法开始", Padding);
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine("{0}开始 yield {1}", i);
            yield return i;
            Console.WriteLine("{0}yield 结束", Padding);
        }
        Console.WriteLine("{0} Yielding最后一个值", Padding);
        yield return -1;
        Console.WriteLine("{0} CreateEnumerable()方法结束", Padding);
    }

    static void Main(string[] args)
    {
        The IEnumerable <Int32> CreateEnumerable Iterable = (); 
        the IEnumerator <Int32> = iterable.GetEnumerator Iterator (); 
        Console.WriteLine ( "iterates"); 
        the while (to true) 
        { 
            Console.WriteLine ( "call to MoveNext method ......"); 
            Result = iterator.MoveNext boolean (); 
            Console.WriteLine ( "the MoveNext method returns 0} {", Result); 
            IF (Result!) 
            { 
                BREAK; 
            } 
            Console.WriteLine ( "Get the current value ......"); 
            Console. WriteLine ( "current value acquired 0} {", iterator.Current); 
        } 
        the Console.ReadKey (); 
    } 
}

In order to show details of iterations, the above code uses a while loop, foreach generally used under normal circumstances. And the last time, this time in an iterative approach in our returns IEnumerable; object rather than IEnumerator; objects. Generally, in order to achieve IEnumerable interface, an object can only return IEnumerator; if naturally want to go back a number of columns of data from one process, the following is used IEnumerable output:

 

The output can be seen from the following points:

  • Until the first call to MoveNext, the method CreateEnumerable was only called.
  • When you call MoveNext, and has already done all the operation and return to the Current property does not execute any code.
  • Code after the yield return will stop the execution, the first call to MoveNext method of waiting time to continue.
  • In the method can have multiple yield return statement.
  • After the last executed yield return is completed, the code is not terminated. Call MoveNext returns false makes the method ends.

    The first point is important: That means, you can not write any code in the method that required immediate execution in the iteration block - such as parameter validation. If the parameter validation on the iteration block, then he will not be able to function well, this is the wrong place often leads, and this error is not easy to find.

    Let's look at how to stop the iteration, as well as special implementation finally statement block.

 

Special execution flow 2.3 iterator

    In the ordinary method, return statements usually has two effects, one is the result of the execution returns to the caller. Second, the implementation of the termination method, the method finally statement is executed before termination. In the example above, we see the yield return statement just short of out of the way when MoveNext again to continue the call. Here we do not write the finally block. How to really exit method, finnally how to block implementation of the statement when exiting the method, let's look at a relatively simple structure: yield break statement block.

Use an iterative yield break ends

    Usually we have to do is to make only one exit point method, typically, multiple exit points of the program will make the code difficult to read, especially when using try catch finally statement block such as exception handling and resource cleanup time. In an iterative block of time you will encounter such a problem, but if you want to quit early iterations, then the use of yield break will be able to achieve the desired effect. He can immediately terminate the iteration, so that the next call to MoveNext when returns false.

The following code demonstrates iteration from 1 to 100, but the timeout when he stopped iteration.

static IEnumerable<Int32> CountWithTimeLimit(DateTime limit)
{
    try
    {
        for (int i = 1; i <= 100; i++)
        {
            if (DateTime.Now >= limit)
            {
                yield break;
            }
            yield return i;
        }
    }
    finally
    {
        Console.WriteLine("停止迭代!"); Console.ReadKey();
    }
}
static void Main(string[] args)
{
    DateTime stop = DateTime.Now.AddSeconds(2);
    foreach (Int32 i in CountWithTimeLimit(stop))
    {
        Console.WriteLine("返回 {0}", i);
        Thread.Sleep(300);
    }
}

FIG output the result can be seen that the iteration statement terminates normally, and the yield return statement return statement in the conventional method as below to see finally block is performed when and how.

 

Finally block is executed

    Typically, finally executed statement block executes in a specific area when the exit method. Iterative finally statement block in an ordinary method and finally blocks are not the same. As we have seen, yield return statement to stop the execution method, rather than exit the method, according to this logic, in this case, finally statement block statement is not executed.

    But yield break statement encountered when finally block executes, as the conventional method in which the root of return. Finally statement is generally used in an iterative block to release resources, just like using the same statement.

    Let's look at how to perform finally statement.

   Whether iteration to 100 times or because the time to stop the iteration, or throws an exception, finally statement is always executed, but in some cases, we do not want the finally block is executed.

    Only after calling MoveNext iteration statement in the block will be executed, if MoveNext can not afford to use it, if you call a few times and then stop calling MoveNext results what will happen? See the following code?

DateTime stop = DateTime.Now.AddSeconds(2);
foreach (Int32 i in CountWithTimeLimit(stop))
{
    if (i > 3)
    {
        Console.WriteLine("返回中^");
        return;
    }
    Thread.Sleep(300);
}

   在forech中,return语句之后,因为CountWithTimeLimit中有finally块所以代码继续执行CountWithTimeLimit中的finally语句块。foreach语句会调用GetEnumerator返回的迭代器的Dispose方法。在结束迭代之前调用包含迭代块的迭代器的Dispose方法时,状态机会执行在迭代器范围内处于暂停状态下的代码范围内的所有finally块,这有点复杂,但是结果很容易解释:只有使用foreach调用迭代,迭代块中的finally块会如期望的那样执行。下面可以用代码验证以上结论:

IEnumerable<Int32> iterable = CountWithTimeLimit(stop);
IEnumerator<Int32> iterator = iterable.GetEnumerator();

iterator.MoveNext();
Console.WriteLine("返回 {0}", iterator.Current);

iterator.MoveNext();
Console.WriteLine("返回 {0}", iterator.Current);
Console.ReadKey();

代码输出如下:

上图可以看出,停止迭代没有打印出来,当我们手动调用iterator的Dispose方法时,会看到如下的结果。在迭代器迭代结束前终止迭代器的情况很少见,也很少不使用foreach语句而是手动来实现迭代,如果要手动实现迭代,别忘了在迭代器外面使用using语句,以确保能够执行迭代器的Dispose方法进而执行finally语句块。 

下面来看看微软对迭代器的一些实现中的特殊行为:

 

2.4 迭代器执行中的特殊行为

 

    如果使用C#2的编译器将迭代块编译,然后使用ildsam或者Reflector查看生成的IL代码,你会发现在幕后编译器回味我们生成了一些嵌套的类型(nested type).下图是使用Ildsam来查看生成的IL ,最下面两行是代码中的的两个静态方法,上面蓝色的<CountWithTimeLimit>d_0是编译器为我们生成的类(尖括号只是类名,和泛型无关),代码中可以看出该类实现了那些接口,以及有哪些方法和字段。大概和我们手动实现的迭代器结构类似。

真正的代码逻辑实在MoveNext方法中执行的,其中有一个大的switch语句。幸运的是,作为一名开发人员没必要了解这些细节,但一些迭代器执行的方式还是值得注意的:

  • 在MoveNext方法第一次执行之前,Current属性总是返回迭代器返回类型的默认的值。例如IEnumeratble返回的是Int32类型,那么默认初始值是0,所以在调用MoveNext方法之前调用Current属性就会返回0。
  • MoveNext方法返回false后,Current属性总是返回最后迭代的那个值。
  • Reset方法一般会抛出异常,而在本文开始代码中,我们手动实现一个迭代器时在Reset中能够正确执行逻辑。
  • 编译器为我们产生的嵌套类会同时实现IEnumerator的泛型和非泛型版本(恰当的时候还会实现IEnumerable的泛型和非泛型版本).

   没有正确实现Reset方法是有原因的--编译器不知道需要使用怎样的逻辑来从新设置迭代器。很多人认为不应该有Reset方法,很多集合并不支持,因此调用者不应该依赖这一方法。

   实现其它接口没有坏处。方法中返回IEnumerable接口,他实现了五个接口(包括IDisposable),作为一个开发者不用担心这些。同时实现IEnumerable和IEnumerator接口并不常见,编译器为了使迭代器的行为总是正常,并且为能够在当前的线程中仅仅需要迭代一个集合就能创建一个单独的嵌套类型才这么做的。

   Current属性的行为有些古怪,他保存了迭代器的最后一个返回值并且阻止了垃圾回收期进行收集。

因此,自动实现的迭代器方法有一些小的缺陷,但是明智的开发者不会遇到任何问题,使用他能够节省很多代码量,使得迭代器的使用程度比C#1中要广。下面来看在实际开发中迭代器简化代码的地方。

 

 

3.实际开发中使用迭代的例子

 

3.1 从时间段中迭代日期

在涉及到时间区段时,通常会使用循环,代码如下:

for (DateTime day = timetable.StartDate; day < timetable.EndDate; day=day.AddDays(1))
{
    ……
}

循环有时没有迭代直观和有表现力,在本例中,可以理解为“时间区间中的每一天”,这正是foreach使用的场景。因此上述循环如果写成迭代,代码会更美观:

foreach(DateTime day in timetable.DateRange)
{
    ……
}

在C#1.0中要实现这个需要下一定功夫。到了C#2.0就变得简单了。在timetable类中,只需要添加一个属性:

public IEnumerable<DateTime> DateRange
{
    get
    {
        for (DateTime day=StartDate ; day < =EndDate; day=day.AddDays(1))
        {
            yield return day;
        }
    } 
}

   只是将循环移动到了timetable类的内部,但是经过这一改动,使得封装变得更为良好。DateRange属性只是遍历时间区间中的每一天,每一次返回一天。如果想要使得逻辑变得复杂一点,只需要改动一处。这一小小的改动使得代码的可读性大大增强,接下来可以考虑将这个Range扩展为泛型Range<T>。

 

3.2迭代读取文件中的每一行

 

读取文件时,我们经常会书写这样的代码:

using (TextReader reader=File.OpenText(fileName))
{
    String line;
    while((line=reader.ReadLine())!=null)
    {
       ……
    }
}

 

这一过程中有4个环节:

  • 如何获取TextReader
  • 管理TextReader的生命周期
  • 通过TextReader.ReadLine迭代所有的行
  • 对行进行处理

可以从两个方面对这一过程进行改进:可以使用委托--可以写一个拥有reader和一个代理作为参数的辅助方法,使用代理方法来处理每一行,最后关闭reader,这经常被用来展示闭包和代理。还有一种更为优雅更符合LINQ方式的改进。除了将逻辑作为方法参数传进去,我们可以使用迭代来迭代一次迭代一行代码,这样我们就可以使用foreach语句。代码如下:

static IEnumerable<String> ReadLines(String fileName)
{
    using (TextReader reader = File.OpenText(fileName))
    {
        String line;
        while ((line = reader.ReadLine()) != null)
        {
            yield return line;
        }
    }
}

这样就可以使用如下foreach方法来读取文件了:

foreach (String line in ReadLines("test.txt"))
{
    Console.WriteLine(line);
}

   方法的主体部分和之前的一样,使用yield return返回了读取到的每一行,只是在迭代结束后有点不同。之前的操作,先打开文档,每一次读取一行,然后在读取结束时关闭reader。虽然”当读取结束时”和之前方法中使用using相似,但当使用迭代时这个过程更加明显。

这就是为什么foreach迭代结束后会调用迭代器的dispose方法这么重要的原因了,这个操作能够保证reader能够得到释放。迭代方法中的using语句块类似与try/finally语句块;finally语句在读取文件结束或者当我们显示调用IEnumerator<String> 的Dispose方法时都会执行。可能有时候会通过ReadLine().GetEnumerator()的方式返回IEnumerator<String> ,进行手动迭代而没有调用Dispose方法,就会产生资源泄漏。通常会使用foreach语句来迭代循环,所以这个问题很少会出现。但是还是有必要意识到这个潜在的问题。

      该方法封装了前三个步骤,这可能有点苛刻。将生命周期和方法进行封装是有必要的,现在扩展一下,假如我们要从网络上读取一个流文件,或者我们想使用UTF-8编码的方法,我们需要将第一个部分暴漏给方法调用者,使得方法的调用签名大致如下:

static IEnumerable<String> ReadLines(TextReader reader) 

这样有很多不好的地方,我们想对reader有绝对的控制,使得调用者能够在结束后能进行资源清理。问题在于,如果在第一次调用MoveNext()之前出现错误,那么我们就没有机会进行资源清理工作了。IEnumerable<String>自身不能释放,他存储了某个状态需要被清理。另一个问题是如果GetEnumerator被调用两次,我们本意是返回两个独立的迭代器,然后他们却使用了相同的reader。一种方法是,将返回类型改为IEnumerator<String>,但这样的话,不能使用foreach进行迭代,而且如果没有执行到MoveNext方法的话,资源也得不到清理。

   幸运的是,有一种方法可以解决以上问题。就像代码不必立即执行,我们也不需要reader立即执行。我们可以提供一个接口实现“如果需要一个TextReader,我们可以提供”。在.NET 3.5中有一个代理,签名如下:

public delegate TResult Func<TResult>()

代理没有参数,返回和类型参数相同的类型。我们想获得TextReader对象,所以可以使用Func<TextReader>,代码如下:

using (TextReader reader=provider())
{
    String line;
    while ((line=reader.ReadLine())!=null)
    {
        yield return line;
    }         
}

 

3.3 使用迭代块和迭代条件来对集合进行进行惰性过滤

   LINQ允许对内存集合或者数据库等多种数据源用简单强大的方式进行查询。虽然C#2没有对查询表达式,lambda表达及扩展方法进行集成。但是我们也能达到类似的效果。

   LINQ的一个核心的特征是能够使用where方法对数据进行过滤。提供一个集合以及过滤条件代理,过滤的结果就会在迭代的时候通过惰性匹配,每匹配一个过滤条件就返回一个结果。这有点像List<T>.FindAll方法,但是LINQ支持对所有实现了IEnumerable<T>接口的对象进行惰性求值。虽然从C#3开始支持LINQ,但是我们也可以使用已有的知识在一定程度上实现LINQ的Where语句。代码如下:

public static IEnumerable<T> Where<T>(IEnumerable<T> source, Predicate<T> predicate)
{
    if (source == null || predicate == null)
        throw new ArgumentNullException();
    return WhereImpl(source, predicate);
}

private static IEnumerable<T> WhereImpl<T>(IEnumerable<T> source, Predicate<T> predicate)
{
    foreach (T item in source)
    {
        if (predicate(item))
            yield return item;
    }
}

IEnumerable<String> lines = ReadLines("FakeLinq.cs");
Predicate<String> predicate = delegate(String line)
{
    return line.StartsWith("using");
}; 

    如上代码中,我们将整个实现分为了两个部分,参数验证和具体逻辑。虽然看起来奇怪,但是对于错误处理来说是很有必要的。如果将这两个部分方法放到一个方法中,如果用户调用了Where<String>(null,null),将不会发生任何问题,至少我们期待的异常没有抛出。这是由于迭代块的惰性求值机制产生的。在用户迭代的时候第一次调用MoveNext方法之前,方法主体中的代码不会执行,就像在2.2节中看到的那样。如果你想急切的对方法的参数进行判断,那么没有一个地方能够延缓异常,这使得bug的追踪变得困难。标准的做法如上代码,将方法分为两部分,一部分像普通方法那样对参数进行验证,另一部分代码使用迭代块对主体逻辑数据进行惰性处理。

    迭代块的主体很直观,对集合中的逐个元素,使用predict代理方法进行判断,如果满足条件,则返回。如果不满足条件,则迭代下一个,直到满足条件为止。如果要在C#1中实现这点逻辑就很困难,特别是实现其泛型版本。

   后面的那段代码演示了使用之前的readline方法读取数据然后用我们的where方法来过滤获取line中以using开头的行,和用File.ReadAllLines及Array.FindAll<String>实现这一逻辑的最大的差别是,我们的方法是完全惰性和流线型的(Streaming)。每一次只在内存中请求一行并对其进行处理,当然如果文件比较小的时候没有什么差别,但是如果文件很大,例如上G的日志文件,这种方法的优势就会显现出来了。

 

4 总结

   C#对许多设计模式进行了间接的实现,使得实现这些模式变得很容易。相对来针对某一特定的设计模式直接实现的的特性比较少。从foreach代码中看出,C#1对迭代器模式进行了直接的支持,但是没有对进行迭代的集合进行有效的支持。对集合实现一个正确的IEnumerable很耗时,容易出错也很很枯燥。在C#2中,编译器为我们做了很多工作,为我们实现了一个状态机来实现迭代。

    本文还展示了和LINQ相似的一个功能:对集合进行过滤。IEnumerable<T>在LINQ中最重要的一个接口,如果想要在LINQ To Object上实现自己的LINQ操作,那么你会由衷的感叹这个接口的强大功能以及C#语言提供的迭代块的用处。

    本文还展示了实际项目中使用迭代块使得代码更加易读和逻辑性更好的例子,希望这些例子使你对理解迭代有所帮助。

Guess you like

Origin blog.csdn.net/qq_26900671/article/details/102728468