0x00 前言

\\

在我之前的一篇博客《细说C#:不是“栈类型”的值类型,从生命周期聊存储位置》的最后,我以总结和后记的方式涉及到一部分迭代器的知识。但是觉得还是不够过瘾,很多需要说清楚的内容还是含糊不清,所以本文就专门写一下c#中的迭代器吧。

\\\\

0x01 你好,迭代器

\\

首先思考一下,在什么情景下我们需要使用到迭代器?

\\

假设我们有一个数据容器(可能是Array,List,Tree等等),对我们这些使用者来说,我们显然希望这个数据容器能提供一种无需了解它的内部实现就可以获取其元素的方法,无论它是Array还是List或者别的什么,我们希望可以通过相同的方法达到我们的目的。

\\

此时,迭代器模式(iterator pattern)便应运而生,它通过持有迭代状态,追踪当前元素并且识别下一个需要被迭代的元素,从而可以让使用者透过特定的界面巡访容器中的每一个元素而不用了解底层的实现。

\\

那么,在c#中,迭代器到底是以一个怎样的面目出现的呢?

\\

如我们所知,它们被封装在IEnumerable和IEnumerator这两个接口中(当然,还有它们的泛型形式,要注意的是泛型形式显然是强类型的。且IEnumerator\u0026lt;T\u0026gt;实现了IDisposable接口)。

\\

IEnumerable非泛型形式:

\\

//IEnumerable非泛型形式\[ComVisibleAttribute(True)]\[GuidAttribute(\"496B0ABE-   CDEE-11d3-88E8-00902754C43A\")]\public interface IEnumerable\{\    IEnumerator GetEnumerator();\}\

\\

IEnumerator非泛型形式:

\\

//IEnumerator非泛型形式\[ComVisibleAttribute(true)]\[GuidAttribute(\"496B0ABF-CDEE-11d3-88E8-00902754C43A\")]\public interface IEnumerator\{\    Object Current {get;}\    bool MoveNext();\    void Reset();\}\

\\

IEnumerable泛型形式:

\\

//IEnumerable泛型形式\public interface IEnumerable\u0026lt;out T\u0026gt; : IEnumerable\{\    IEnumerator\u0026lt;T\u0026gt; GetEnumerator();\    IEnumerator GetEnumerator(); \}\

\\

IEnumerator泛型形式:

\\

//IEnumerator泛型形式\public interface IEnumerator\u0026lt;out T\u0026gt; : IDisposable, IEnumerator\{\\    void Dispose(); \    Object Current {get;} \    T Current {get;}\    bool MoveNext(); \    void Reset(); \}\\[ComVisibleAttribute(true)]\public interface IDisposable\{\    void Dispose();\}\

\\

IEnumerable接口定义了一个可以获取IEnumerator的方法——GetEnumerator()。

\\

而IEnumerator则在目标序列上实现循环迭代(使用MoveNext()方法,以及Current属性来实现),直到你不再需要任何数据或者没有数据可以被返回。使用这个接口,可以保证我们能够实现常见的foreach循环。

\\\\

为什么会有2个接口?

\\

到此,各位看官是否和曾经的我有相同的疑惑呢?那就是为何IEnumerable自己不直接实现MoveNext()方法、提供Current属性呢?为何还需要额外的一个接口IEnumerator来专门做这个工作?

\\

OK,假设有两个不同的迭代器要对同一个序列进行迭代。当然,这种情况很常见,比如我们使用两个嵌套的foreach语句。我们自然希望两者相安无事,不要互相影响彼此。所以自然而然的,我们需要保证这两个独立的迭代状态能够被正确的保存、处理。这也正是IEnumerator要做的工作。而为了不违背单一职责原则,不使IEnumerable拥有过多职责从而陷入分工不明的窘境,所以IEnumerable自己并没有实现MoveNext()方法。

\\\\

迭代器的执行步骤

\\

为了更直观的了解一个迭代器,我这里提供一个小例子。

\\

using System;\using System.Collections.Generic;\\class Class1\{ \    static void Main()\    {\        foreach (string s in GetEnumerableTest())\        {\            Console.WriteLine(s);\        }\    }\\     static IEnumerable\u0026lt;string\u0026gt; GetEnumerableTest()\    {\        yield return \"begin\";\\        for (int i=0; i \u0026lt; 10; i++)\        {\            yield return i.ToString();\        }\\        yield return \"end\";\    }\}\

\\

输出结果如图:

\\

\\

OK,那么我就给各位捋一下这段代码的执行过程。

\\

  1. Main调用GetEnumerableTest()方法。\
  2. GetEnumerableTest()方法会为我们创建一个编译器生成的新的类”Class1/’c__Iterator0’”(本例中)的实例。注意,此时GetEnumerableTest()方法中,我们自己的代码尚未执行。\
  3. Main调用MoveNext()方法。\
  4. 迭代器开始执行,直到它遇到第一个yield return语句。此时迭代器会获取当前的值是“begin”,并且返回true以告知此时还有数据。\
  5. Main使用Current属性以获取数据,并打印出来。\
  6. Main再次调用MoveNext()方法。\
  7. 迭代器继续从上次遇到yield return的地方开始执行,并且和之前一样,直到遇到下一个yield return。\
  8. 迭代器按照这种方式循环,直到MoveNext()方法返回false,以告知此时已经没有数据了。\

这个例子中迭代器的执行过程,我已经给各位看官简单的描述了一下。但是还有几点需要关注的,我也想提醒各位注意一下。

\\

  • 在第一次调用MoveNext()方法之前,我们自己在GetEnumerableTest中的代码不会执行。\
  • 之后调用MoveNext()方法时,会从上次暂停(yield return)的地方开始。\
  • 编译器会保证GetEnumerableTest方法中的局部变量能够被保留,换句话说,虽然本例中的i是值类型实例,但是它的值其实是被迭代器保存在堆上的,这样才能保证每次调用MoveNext时,它是可用的。这也是我之前的一篇文章中说迭代器块中的局部变量会被分配在堆上的原因。\

好了,简单总结了一下C#中的迭代器的外观。那么接下来,我们继续向内部前进,来看看迭代器究竟是如何实现的。

\\\\

0x02 原来是状态机呀

\\

上一节我们已经从外部看到了IEnumerable和IEnumerator这两个接口的用法了,但是它们的内部到底是如何实现的呢?两者之间又有何区别呢?

\\

既然要深入迭代器的内部,这就是一个不得不面对的问题。

\\

那么我就写一个小程序,之后再通过反编译的方式,看看在我们自己手动写的代码背后,编译器究竟又给我们做了哪些工作吧。

\\

为了简便起见,这个小程序仅仅实现一个按顺序返回0-9这10个数字的功能。

\\\\

IEnumerator的内部实现

\\

首先,我们定义一个返回IEnumerator的方法TestIterator()。

\\

//IEnumerator\u0026lt;T\u0026gt;测试\using System;\using System.Collections;\\class Test\{\    static IEnumerator\u0026lt;int\u0026gt; TestIterator()\    {\        for (int i = 0; i \u0026lt; 10; i++)\        {\            yield return i;\        }\    }\}\

\\

接下来,我们看看反编译之后的代码,探查一下编译器到底为我们做了什么吧。

\\

internal class Test\{\    // Methods 注,此时还没有执行任何我们写的代码\    private static IEnumerator\u0026lt;int\u0026gt; TestIterator()\    {\        return new \u0026lt;TestIterator\u0026gt;d__0(0);\    }\\    // Nested Types 编译器生成的类,用来实现迭代器。\    [CompilerGenerated]\    private sealed class \u0026lt;TestIterator\u0026gt;d__0 : IEnumerator\u0026lt;int\u0026gt;, IEnumerator, IDisposable\    {\        // Fields 字段:state和current是默认出现的\        private int \u0026lt;\u0026gt;1__state;\        private int \u0026lt;\u0026gt;2__current;\        public int \u0026lt;i\u0026gt;5__1;//\u0026lt;i\u0026gt;5__1来自我们迭代器块中的局部变量\\        // Methods 构造函数,初始化状态\        [DebuggerHidden]\        public \u0026lt;TestIterator\u0026gt;d__0(int \u0026lt;\u0026gt;1__state)\        {\            this.\u0026lt;\u0026gt;1__state = \u0026lt;\u0026gt;1__state;\        }\        // 几乎所有的逻辑在这里\        private bool MoveNext()\        {\            switch (this.\u0026lt;\u0026gt;1__state)\            {\                case 0:\                    this.\u0026lt;\u0026gt;1__state = -1;\                    this.\u0026lt;i\u0026gt;5__1 = 0;\                    while (this.\u0026lt;i\u0026gt;5__1 \u0026lt; 10)\                    {\                        this.\u0026lt;\u0026gt;2__current = this.\u0026lt;i\u0026gt;5__1;\                        this.\u0026lt;\u0026gt;1__state = 1;\                        return true;\                    Label_0046:\                        this.\u0026lt;\u0026gt;1__state = -1;\                        this.\u0026lt;i\u0026gt;5__1++;\                    }\                    break;\\                case 1:\                    goto Label_0046;\            }\            return false;\        }\\        [DebuggerHidden]\        void IEnumerator.Reset()\        {\            throw new NotSupportedException();\        }\\        void IDisposable.Dispose()\        {\        }\\        // Properties\        int IEnumerator\u0026lt;int\u0026gt;.Current\        {\            [DebuggerHidden]\            get\            {\                return this.\u0026lt;\u0026gt;2__current;\            }\        }\\        object IEnumerator.Current\        {\            [DebuggerHidden]\            get\            {\                return this.\u0026lt;\u0026gt;2__current;\            }\        }\    }\}\

\\

我们先全面的看一下反编译之后的代码,可以发现几乎所有的逻辑都发生在MoveNext()方法中。那么之后我们再详细介绍下它,现在我们先从上到下把代码捋一遍。

\\

  1. 这段代码给人的第一印象就是命名似乎很不雅观。的确,这种在正常的C#代码中不会出现的命名,在编译器生成的代码中却是常常出现。因为这样就可以避免和已经存在的正常名字发生冲突的可能性。\
  2. 调用TestIterator()方法的结果仅仅是调用了\u0026lt;TestIterator\u0026gt;d__0(编译器生成的用来实现迭代器的类)的构造函数。而这个构造函数会设置迭代器的初始状态,此时的参数为0,而构造函数会将0赋值给记录迭代器状态的字段:this.\u0026lt;\u0026gt;1__state = \u0026lt;\u0026gt;1__state;。注意,此时我们自己的代码并没有执行。\
  3. \u0026lt;TestIterator\u0026gt;d__0这个类实现了3个接口:IEnumerator\u0026lt;int\u0026gt;, IEnumerator, IDisposable。\
  4. IDisposable的实现十分重要。因为foreach语句会在它自己的finally代码块中调用实现了IDisposable接口的迭代器的Dispose方法。\
  5. \u0026lt;TestIterator\u0026gt;d__0类有3个字段:\u0026lt;\u0026gt;1__state\u0026lt;\u0026gt;2__current\u0026lt;i\u0026gt;5__1。其中,\u0026lt;\u0026gt;1__state私有字段标识迭代器的状态,\u0026lt;\u0026gt;2__current私有字段则追踪当前的值,而\u0026lt;i\u0026gt;5__1共有字段则是我们在迭代器块中定义的局部变量i。\
  6. MoveNext()方法的实现则依托与switch语句。根据状态机的状态,执行不同的代码。\
  7. 在本例中Dispose方法什么都没有做。\
  8. 在IEnumerator和IEnumerator\u0026lt;int\u0026gt;的实现中,Current都是单纯的返回\u0026lt;\u0026gt;2__current的值。\

OK,IEnumerator接口我们看完了。下面再来看看另一个接口IEnumerable吧。

\\\\

IEnumerator VS IEnumerable

\\

依样画葫芦,这次我们仍然是写一个实现按顺序返回0-9这10个数字的功能的小程序,只不过返回类型变为IEnumerable\u0026lt;T\u0026gt;

\\

using System;\using System.Collections.Generic;\\class Test\{\    static IEnumerable\u0026lt;int\u0026gt; TestIterator()\    {\        for (int i = 0; i \u0026lt; 10; i++)\        {\            yield return i;\        }\    }\}\

\\

之后,我们同样通过反编译,看看编译器又背着我们做了什么。

\\

internal class Test\{\    private static IEnumerable\u0026lt;int\u0026gt; TestIterator()\    {\        return new \u0026lt;TestIterator\u0026gt;d__0(-2);\    }\\    private sealed class \u0026lt;TestIterator\u0026gt;d__0 : IEnumerable\u0026lt;int\u0026gt;, IEnumerable, IEnumerator\u0026lt;int\u0026gt;, IEnumerator, IDisposable\    {\        // Fields\        private int \u0026lt;\u0026gt;1__state;\        private int \u0026lt;\u0026gt;2__current;\        private int \u0026lt;\u0026gt;l__initialThreadId;\        public int \u0026lt;count\u0026gt;5__1;\\        public \u0026lt;TestIterator\u0026gt;d__0(int \u0026lt;\u0026gt;1__state)\        {\            this.\u0026lt;\u0026gt;1__state = \u0026lt;\u0026gt;1__state;\            this.\u0026lt;\u0026gt;l__initialThreadId = Thread.CurrentThread.ManagedThreadId;\        }\\        private bool MoveNext()\        {\            switch (this.\u0026lt;\u0026gt;1__state)\            {\                case 0:\                    this.\u0026lt;\u0026gt;1__state = -1;\                    this.\u0026lt;count\u0026gt;5__1 = 0;\                    while (this.\u0026lt;count\u0026gt;5__1 \u0026lt; 10)\                    {\                        this.\u0026lt;\u0026gt;2__current = this.\u0026lt;count\u0026gt;5__1;\                        this.\u0026lt;\u0026gt;1__state = 1;\                        return true;\                    Label_0046:\                        this.\u0026lt;\u0026gt;1__state = -1;\                        this.\u0026lt;count\u0026gt;5__1++;\                    }\                    break;\\                case 1:\                    goto Label_0046;\            }\            return false;\        }\\        IEnumerator\u0026lt;int\u0026gt; IEnumerable\u0026lt;int\u0026gt;.GetEnumerator()\        {\            if ((Thread.CurrentThread.ManagedThreadId == this.\u0026lt;\u0026gt;l__initialThreadId) \u0026amp;\u0026amp; (this.\u0026lt;\u0026gt;1__state == -2))\            {\                this.\u0026lt;\u0026gt;1__state = 0;\                return this;\            }\            return new Test.\u0026lt;TestIterator\u0026gt;d__0(0);\        }\\        IEnumerator IEnumerable.GetEnumerator()\        {\            return ((IEnumerable\u0026lt;Int32\u0026gt;) this).GetEnumerator();\        }\\        void IEnumerator.Reset()\        {\            throw new NotSupportedException();\        }\\        void IDisposable.Dispose()\        {\        }\\        int IEnumerator\u0026lt;int\u0026gt;.Current\        {\            get\            {\                return this.\u0026lt;\u0026gt;2__current;\            }\        }\\        object IEnumerator.Current\        {\            get\            {\                return this.\u0026lt;\u0026gt;2__current;\            }\        }\    }\}\

\\

看到反编译出的代码,我们就很容易能对比出区别。

\\

  1. \u0026lt;TestIterator\u0026gt;d__0类不仅实现了IEnumerable\u0026lt;int\u0026gt; 接口,而且还实现了IEnumerator\u0026lt;int\u0026gt;接口。\
  2. IEnumerator和IEnumerator\u0026lt;int\u0026gt;的实现都和上面一样。IEnumerator的Reset方法会抛出NotSupportedException异常,而IEnumerator和IEnumerator\u0026lt;int\u0026gt;的Current仍旧会返回\u0026lt;\u0026gt;2__current字段的值。\
  3. TestIterator()方法调用\u0026lt;TestIterator\u0026gt;d__0类的构造函数时,传入的参数由上面的0变成了-2:new \u0026lt;TestIterator\u0026gt;d__0(-2);。也就是说此时的初始状态是-2。\
  4. 又多了一个新的私有字段\u0026lt;\u0026gt;l__initialThreadId,且会在\u0026lt;TestIterator\u0026gt;d__0的构造函数中被赋值,用来标识创建该实例的线程。\
  5. 实现IEnumerable的GetEnumerator方法,在GetEnumerator方法中要么将状态置为0,并返回this:this.\u0026lt;\u0026gt;1__state = 0;return this;要么就返回一个新的\u0026lt;TestIterator\u0026gt;d__0实例,且初始状态置为0:return new Test.\u0026lt;TestIterator\u0026gt;d__0(0);\

所以,从这些对比中我们能发现些什么吗?思考一下我们经常使用的一些用法,包括我在上一节中提供的小例子。不错,我们会创建一个IEnumerable\u0026lt;T\u0026gt;的实例,之后一些语句(例如foreach)会去调用GetEnumerator方法获取一个Enumerator\u0026lt;T\u0026gt;的实例,之后迭代数据,最终结束后释放掉迭代器的实例(这一步foreach会帮我们做)。

\\

而分析IEnumerable的GetEnumerator方法:

\\

IEnumerator\u0026lt;int\u0026gt; IEnumerable\u0026lt;int\u0026gt;.GetEnumerator()\{\    if ((Thread.CurrentThread.ManagedThreadId == this.\u0026lt;\u0026gt;l__initialThreadId) \u0026amp;\u0026amp; (this.\u0026lt;\u0026gt;1__state == -2))\    {\        this.\u0026lt;\u0026gt;1__state = 0;\        return this;\    }\    return new Test.\u0026lt;TestIterator\u0026gt;d__0(0);\}\

\\

我们可以发现,-2这个状态,也就是此时的初始状态,表明了GetEnumerator()方法还没有执行。而0这个状态,则表明已经准备好了迭代,但是MoveNext()尚未调用过。

\\

当在不同的线程上调用GetEnumerator方法或者是状态不是-2(证明已经不是初始状态了),则GetEnumerator方法会返回一个\u0026lt;TestIterator\u0026gt;d__0类的新实例用来保存不同的状态。

\\\\

0x03 状态管理

\\

OK,我们深入了迭代器的内部,发现了原来它的实现主要依靠的是一个状态机。那么,下面就让我继续和大伙聊聊这个状态机是如何管理状态的。

\\\\

状态切换

\\

根据Ecma-334标准,也就是c#语言标准的第26.2 Enumerator objects小节,我们可以知道迭代器有4种可能状态:

\\

  1. before状态\
  2. running状态\
  3. suspended状态\
  4. after状态\

而其中before状态是作为初始状态出现的。

\\

在我们讨论状态如何切换之前,我还要带领大家回想一下上面提到的,也就是在调用一个使用了迭代器块,返回类型为一个IEnumerator或IEnumerable接口的方法时,这个方法并非立刻执行我们自己写的代码的。而是会创建一个编译器生成的类的实例,之后当调用MoveNext()方法时(当然如果方法的返回类型是IEnumerable,则要先调用GetEnumerator()方法),我们的代码才会开始执行,直到遇到第一个yield return语句或yield break语句,此时会返回一个布尔值来判断迭代是否结束。当下次再调用MoveNext()方法时,我们的方法会继续从上一个yield return语句处开始执行。

\\

为了能够直观的观察状态的切换,下面我提供另一个例子:

\\

class Test\{\\    static IEnumerable\u0026lt;int\u0026gt; TestStateChange()\    {\        Console.WriteLine(\"----我TestStateChange是第一行代码\");\        Console.WriteLine(\"----我是第一个yield return前的代码\");\        yield return 1;\        Console.WriteLine(\"----我是第一个yield return后的代码\");\\        Console.WriteLine(\"----我是第二个yield return前的代码\");\        yield return 2;\        Console.WriteLine(\"----我是第二个yield return前的代码\");\    }\\    static void Main()\    {\        Console.WriteLine(\"调用TestStateChange\");\        IEnumerable\u0026lt;int\u0026gt; iteratorable = TestStateChange();\        Console.WriteLine(\"调用GetEnumerator\");\        IEnumerator\u0026lt;int\u0026gt; iterator = iteratorable.GetEnumerator();\        Console.WriteLine(\"调用MoveNext()\");\        bool hasNext = iterator.MoveNext();\        Console.WriteLine(\"是否有数据={0}; Current={1}\

庖丁解牛迭代器,聊聊那些藏在幕后的秘密相关推荐

  1. 匹夫细说C#:庖丁解牛迭代器,那些藏在幕后的秘密

    匹夫细说C#:庖丁解牛迭代器,那些藏在幕后的秘密 c#语言规范 阅读目录 0x00 前言 0x01 你好,迭代器 0x02 原来是状态机呀 0x03 状态管理 0x04 总结 回到目录 0x00 前言 ...

  2. 庖丁解牛迭代器,聊聊那些藏在幕后的秘密 1

    0x00 前言 在我之前的一篇博客<细说C#:不是"栈类型"的值类型,从生命周期聊存储位置>的最后,我以总结和后记的方式涉及到一部分迭代器的知识.但是觉得还是不够过瘾, ...

  3. 华为、腾讯、百度扎堆进入的自动驾驶仿真市场,到底藏着怎样的秘密?

    文 | 魏启扬 来源 | 智能相对论(ID:aixdlun) 提到自动驾驶的商业竞争,车联网无疑是其中最为惨烈的一个领域,里面充斥着各种巨头博弈.屌丝逆袭的励志故事. 可是,自动驾驶赛道的复杂性远远不 ...

  4. 麻瓜们不知道的那些被环球影城藏起来的5G秘密

    各位麻瓜你们好欢迎大家来到环球影城今天麻瓜小青将带大家揭开那些被环球影城藏起来的5G秘密~~~~ 首先先让小青啰嗦的介绍下啥是5G!为啥环球影城需要5G,它把5G都藏在哪里了? 众所周知,我国在大力推 ...

  5. 史上最神奇的公式,竟然藏着这么多秘密!

    全世界只有3.14 % 的人关注了 青少年数学之旅 前两天,我们的[欧拉公式-数学史上最强公式]数学经典文化衫首发众筹! 没想到短短几天时间文化衫就售罄了,其火爆程度远远超出了超模君的预期,甚至连不少 ...

  6. 小学就会背的乘法表,还藏着这么多秘密?

    不要走开,精彩马上开始! 乘法表可以追溯到4000多年前的巴比伦人.最早的十进制的例子出现在大约公元前300年的中国,由竹简制作的乘法表可以计算小于99.5的整数和半整数的乘积:此外我们可辨认的还有大 ...

  7. @程序员,紧急公告:你的头像藏着这样的秘密……

    好嗨呦,明天就要放假咯,发条Blink~ 昨天,CSDN上线一个显示用户注册年龄的Logo,右小角的数字即是您在CSDN上的时间: 看到数字的那一刻,不少人一边感慨时间就这么悄悄溜走,一边在群里愉快地 ...

  8. 入门 | 区块链内藏着怎样的秘密?轻信人言不如自己来找

    翻译 | 王国玺 编辑 | 波波 今天币价涨了,明天币价跌了--币价在媒体笔下的涨涨跌跌,似乎成了区块链留给普通人的唯一印象. 而事实却是,绝大部分普通人甚至连区块链的门都没摸过,更别提真的去探索区块 ...

  9. php. 生成器 send,PHP生成器细说

    之前写过关于生成器的文章,可能还不够详细,正好群里有朋友在讨论.觉得还是有必要再细说下,如果大家做过Python或者其他语言的,对于生成器应该不陌生.生成器是PHP 5.5.才引入的功能,也许大家觉得 ...

最新文章

  1. “上拉电阻与下拉电阻”通俗解读
  2. Kubernetes里ingress配置的一些例子
  3. 一文理清HashMap的实现及细节
  4. 重置Oracle密码
  5. Java-当前对象this
  6. java多线程方式轮询,深入理解JAVA多线程之线程间的通信方式
  7. 《BREW进阶与精通——3G移动增值业务的运营、定制与开发》连载之22---BDS的分发流程...
  8. spring5.0学习笔记5
  9. toString()和valueOf()重写的区别
  10. C# 使用 Task 替换 ThreadPool ,异步监测所有线程(任务)是否全部执行完毕
  11. linux的for循环乘积,最大乘积连续子串 - Triangle23 - OSCHINA - 中文开源技术交流社区...
  12. 安卓投屏软件_免费领取15天懒人听书会员+安卓美食菜谱整合app+安卓乐播投屏+办公软件幕布405天免费领取...
  13. 支付宝 APP登录 获取用户信息 PHP
  14. gethostbyname和struct hostent详解
  15. 流体的“流线”和“迹线”定义和区别。
  16. WEB端和手机端-三种提示框架
  17. 计算机无响应 win7,Win7系统计算机关闭时无响应的解决方案
  18. SaaS、PaaS、IaaS简介
  19. 配置通过STelnet登录华为S5700交换机
  20. vCenter命令行升级

热门文章

  1. K3ERP连接数据库问题
  2. 抓豆瓣的电影评论数据
  3. keystone java,搭建KeystoneJS
  4. python app模块_pythonWeb框架创建app模块以及虚拟环境管理工具
  5. 2021年需要学习Python的自动化测试框架有哪些?你知道吗?
  6. IT职场:程序员如何增加收入?
  7. matlab与python通信_python和matlab之间数据传输方法
  8. JSP 自定义标签介绍
  9. 易想团购 注入 user.php,易想团购系统通杀SQL注入漏洞分析及利用漏洞预警 -电脑资料...
  10. Food HDU - 4292 网络流