.NET面试题系列[12] - C# 3.0 LINQ的准备工作
"为了使LINQ能够正常工作,代码必须简化到它要求的程度。" - Jon Skeet
为了提高园子中诸位兄弟的英语水平,我将重要的术语后面配备了对应的英文。
隐式类型的局部变量
隐式类型允许你用var修饰类型。用var修饰只是编译器方便我们进行编码,类型本身仍然是强类型的,所以当编译器无法推断出类型时(例如你初始化一个变量却没有为其赋值,或赋予null,此时就无法推断它的类型),用var修饰就会发生错误。另外,只能对局部变量使用隐式类型。
使用隐式类型的几个时机:
- 当变量的类型太长或者难以推测,但类型本身不重要时,比如你的LINQ语句中用了Groupby,那么一般来说基本很少人可以准确地推测出结果的类型吧。。。
- 当变量初始化时,此时可以根据new后面的类型得知变量类型,故不会对可读性造成影响
- 在Foreach循环中你迭代的对象,此时一般不需要显式指出类型
总的来说,如果使用隐式类型导致你的代码的可读性下降了,那么就改用显式类型。一般第二条原则已经是一个不成文的规定了。Resharper在检测到变量初始化时,如果你没有使用隐式类型,也会提醒你可以用var代替之。
LINQ中隐式类型的体现:你可以统统用var来修饰LINQ语句返回的类型。一般来说LINQ语句的返回类型通常名字都比较长,而且也不是十分显而易见。如果没有隐式类型,在写代码时就会比较痛苦。
自动实现的属性
现在应该满世界都在用自动实现的属性了。注意在结构体中使用自动实现的属性(注意字段不需要),需要显式的调用无参构造函数this()。这是结构体和类的一个区别。
public struct Foo
{
public int a { get; private set; } Foo(int A) : this()
{
a = A;
}
}
上面代码如果去掉this()将会发生错误,在默认无参构造函数将结构体的属性设为默认值之前,不能使用这些属性。如果将上面代码的属性改为字段,则即使不调用this()也不会有问题。
匿名类型(Anonymous Type)
匿名类型允许你直接在括号中建立一个类型。虽然不需要指定成员的具体类型,但匿名类型的成员都是强类型的。
static void Main(string[] args)
{
var tom = new {Name = "Tom", Age = };
Console.WriteLine("{0}: {1}", tom.Name, tom.Age);
}
对匿名类型进行初始化之后,就可以如同实际类型一样使用点符号获取匿名类型的成员,但变量tom只能用var或者object修饰。如果两个匿名类型有相同数量的成员,且所有成员拥有相同的类型名称和值的类型,而且以相同的顺序出现,则编译器会将它们看作是同一个类型。
static void Main(string[] args)
{
var family = new[]
{
new {Name = "Tom", Age = },
new {Name = "Jerry", Age = }
};
var cat = new {Age = , Name = "Cat"};
var dog = new {Age = , Name = "Dog"}; }
如果在初始化中交换了属性的顺序,或者某个属性使用了long而不是int,则会引入一个新的匿名类型。
匿名类型包含了一个默认的构造函数,它获取你赋予的所有初始值。另外,它包含了你定义的类型成员,以及继承自object类型的若干方法(重写的Equals, 重写的GetHashCode, ToString等等)。同一个匿名类型的两个实例在判断相等性时,采用的是依次比较每个成员的值的方式。
在LINQ中,我们可以使用匿名类型来装载查询返回的数据,尤其是最后使用Select或SelectMany等方法返回若干列时。在每次查询都要为返回数据定制一个类显得太繁琐了,虽然有时候是需要的(ViewModel),但也有时候只是为了一次性的展示数据。如果你要创建的类型只在一个方法中使用,而且其中只有简单的字段或者属性而没有方法,则可以考虑使用匿名类型。
表达式和表达式树(Expression & Expression Tree)
Express是表达的意思(它还有很多其他意思,例如快速的),加上名词后缀-sion即为表达式。
表达式是当今编程语言中最重要的组成成分。简单的说,表达式就是变量、数值、运算符、函数组合起来,表示一定意义的式子。例如下面这些都是(C#的)表达式:
- 3 //常数表达式
- a //变量或参数表达式
- !a //一元逻辑非表达式
- a + b //二元加法表达式
- Math.Sin(a) //方法调用(lambda)表达式
- new StringBuilder() //new 表达式
表达式的一个重要的特点是它可以无限组合,只要符合正确的类型和语义。表达式树则是将表达式转换为树形结构,其中每个节点都是表达式。表达式树通常被用于转换为其他形式的代码。例如LINQ to SQL将表达式树转译为SQL。
最基本的几种表达式
- 常量表达式:Expression.Constant(常量的值);
- 变量表达式:Expression.Parameter(typeof(变量类型), "变量名称")
- 二元表达式,即需要两个表达式作为参数进行操作的表达式:Expression.[某个二元表达式的方法,例如加减乘除,模运算等](表达式1, 表达式2);
- Lambda表达式:表达一个方法,可以接受一个代码段或一个方法调用表达式作为方法,以及一组方法参数。Lambda为一希腊字母,无法翻译。希腊字母还有很多,例如阿尔法,贝塔等。之所以选择这个字母是因为来自数学上的原因(数学上有lambda运算)
构建一个最简单的表达式树1+2+3
表达式树是对象构成的树,其中每个节点都是表达式。可以说,每个表达式都是一个表达式树,特别的,某些表达式可以看成只有一个节点的表达式树,例如常量表达式。System.Linq.Expressions命名空间下的Expression类和它的诸多子类就是这一数据结构的实现。Expression类是一个抽象类,主要包含一些静态工厂方法。Expression类也包含两个属性:
- Type:代表表达式求值之后的.net类型,例如Expression.Constant(1)和Expression.Add(Expression.Constant(1), Expression.Constant(2))的类型都是Int32。
- NodeType:代表表达式的种类。例如Expression.Constant(1)的种类是Constant,Expression.Add(Expression.Constant(1), Expression.Constant(2))的种类是Add。
每个表达式都可以表示成Expression某个子类的实例。例如BinaryExpression就表示各种二元运算符(例如加减乘除)的表达式。它需要两个运算数(注意运算数也是表达式):
public static BinaryExpression Add(Expression left, Expression right);
Expression各个子类的构造函数都是不公开的,要创建表达式树只能使用Expression类提供的静态方法。
要创建一个表达式树,首先我们要画出这个树,并找出它需要什么类型的表达式。例如如果我们要创建1 + 2 + 3这个表达式的表达式树,因为它太简单而且不包含多于一种运算(如果有加有乘还要考虑优先级),我们可以一眼看出,其只需要两种表达式,常量表达式(形容1,2,3)和二元表达式(形容加法),所以可以这样写:
ConstantExpression exp1 = Expression.Constant();
ConstantExpression exp2 = Expression.Constant();
BinaryExpression exp12 = Expression.Add(exp1, exp2);
ConstantExpression exp3 = Expression.Constant();
BinaryExpression exp123 = Expression.Add(exp12, exp3);
这个应该非常好理解。但如果我们想写出Math.Sin(a)这个表达式的表达式树怎么办呢?为了解决这个问题,Lambda表达式登场了,它可以表示一个方法。
使用Lambda表达式表示一个函数
我们的目标是使用Lambda表达式表示Math.Sin(a)这个表达式。Lambda表达式代表一个函数,现在它具有一个输入a(我们使用变量表达式ParameterExpression来代表,它应该是double类型),以及一个方法调用,这需要MethodCallExpression类型的表达式,方法名为Sin,位于Math类中。我们需要使用反射找出这个方法。
代码如下:
ParameterExpression expA = Expression.Parameter(typeof(double), "a"); //参数a
MethodCallExpression expCall = Expression.Call(typeof(Math).GetMethod("Sin", BindingFlags.Static | BindingFlags.Public), expA); //Math.Sin(a)
LambdaExpression exp = Expression.Lambda(expCall, expA); // a => Math.Sin(a)
使用Lambda表达式:通过Expression<TDelegate>
Expression<TDelegate>泛型类继承了LambdaExpression类型,它的构造函数接受一个Lambda表达式。此处TDelegate指泛型委托,它可以是Func或者Action。泛型类以静态的方式确定了返回类型和参数的类型。
对于上个例子,我们的输入和输出均为一个Double类型,故我们需要的委托类型是Func<double, double>:
Expression<Func<double, double>> exp2 = d => Math.Sin(d);
可以使用Compile方法将Expression<TDelegate>编译成TDelegate类型(在这个例子中,编译之后的对象类型为Func<double,double>),这是一个将表达式树编译为委托的简便方法(不需要再一步一步来,并且使用反射了)。编译器自动实现转换。
然后就可以直接调用,获得表达式计算的结果:
Expression<Func<double, double>> exp2 = d => Math.Sin(d);
Func<double, double> func = exp2.Compile();
Console.WriteLine(func(0.5));
练习:使用两种方法构建表达式树(a, b, m, n) => m * a * a + n * b * b
假定所有的变量类型都是double。
代码法:
//(a, b, m, n) => m * a * a + n * b * b
ParameterExpression expA = Expression.Parameter(typeof(double), "a"); //参数a
ParameterExpression expB = Expression.Parameter(typeof(double), "b"); //参数b
ParameterExpression expM = Expression.Parameter(typeof(double), "m"); //参数m
ParameterExpression expN = Expression.Parameter(typeof(double), "n"); //参数n BinaryExpression multiply1 = Expression.Multiply(expM, expA);
BinaryExpression multiply2 = Expression.Multiply(multiply1, expA);
BinaryExpression multiply3 = Expression.Multiply(expN, expB);
BinaryExpression multiply4 = Expression.Multiply(multiply3, expB);
BinaryExpression add = Expression.Add(multiply2, multiply4);
委托法:
Expression<Func<double, double, double, double, double>> exp4 = (a, b, m, n) => m*a*a + n*b*b; var ret = exp4.Compile();
Console.WriteLine(ret.Invoke(, , , )); // =3*1*1+4*2*2=3+16=19
通过Expression<TDelegate>以及Compile方法,我们可以方便的计算表达式的结果。但如果一步步来,我们还需要手动遍历这棵树。既然使用代码构造表达式如此麻烦,为什么还要这样做呢?只是因为在手动遍历和计算表达式结果时,可以插入其他操作。LINQ to SQL就是通过递归遍历表达式树,将LINQ语句转换为SQL查询的,这是委托所不能替代的。
不是所有的Lambda表达式都能转化成表达式树。不能将带有一个代码块的Lambda转化成表达式树。表达式中还不能有赋值操作,因为在表达式树中表示不了这种操作。
参考资料:表达式树上手指南 http://www.cnblogs.com/Ninputer/archive/2009/08/28/expression_tree1.html
扩展方法(Extension Method)
扩展方法可以理解成,为现有的类型(现有类型可以为自定义的类型和.Net 类库中的类型)扩展(添加)一些功能,附加到该类型中。
当我们要扩展某个类的功能时,有以下几种方法:一是直接修改类的代码,这可能会导致向后兼容的破坏(不符合开闭原则)。一是派生子类,但这增加了维护的工作量,而且对于结构和密封类根本不能这么做。扩展方法允许我们在不创建子类,不更改类型本身的情况下,仍然可以修改类型。
扩展方法必须定义于静态的类型中,且所有的扩展方法必须是静态的。还是那句话,当你了解了类型对象时,你就很自然的理解了为何扩展方法必须是静态的。(它自类型对象被创建时就应当在对象的方法表中)
扩展方法的第一个输入参数要加上this(第一个参数的类型表示被扩展的类型)。扩展方法必须至少要有一个输入参数。
被扩展的类型的所有子类自动获得该扩展方法。
当你的工程内有特定逻辑,且其基于一个比较普遍的类时,考虑使用扩展方法。如果你想为类型添加一些成员,但又不能更改类型本身(因为不属于你)时,考虑使用扩展方法。例如当你需要频繁判断字符串是否为Email时,你可以扩展String类,将这个判断方法单独置于一个叫做StringExtension的类型中,方便管理。之后你就可以通过调用String.IsEmail来方便的使用这个方法了。
C#中提供了两个特别醒目的类:Enumerable和Queryable。两者都在System.Linq命名空间中。在这两个类中,含有许许多多的扩展方法。Enumerable的大多数扩展的是IEnumerable<T>,Queryable的大多数扩展的是IQueryable<T>。它们赋予了集合强大的查询能力,共同构成了LINQ的重要基础。
什么是闭包(Closure)?C#如何实现一个闭包?
闭包是一种语言特性,它指的是某个函数获取到在其作用域外部的变量,并可以与之互动。Closure这个单词显然来自动词close,有点动词名词化的意思。
通过匿名函数或者lambda表达式,我们可以实现一个简单的闭包:
static void Main(string[] args)
{
//外部变量
var i = ;
//lambda表达式捕获外部变量
//在外部变量的作用域内声明了一个方法
MethodInvoker m = () =>
{
//使用外部变量
i = i + ;
}; m.Invoke();
//打印出1
Console.WriteLine(i);
}
此处函数和来自外部的变量i进行了互动。
匿名函数(Anonymous Function)
匿名函数出现于C# 2.0,它允许在一个委托实例的创建位置内联地指定其操作。
例如我们可以这样写:
Compare(c1, c2, delegate(Circle a, Circle b)
{
if (a.Radius > b.Radius) return ;
if (a.Radius < b.Radius) return -;
return ;
});
匿名方法的语法:先是一个delegate关键字,再是参数(如果有的话),随后是一个代码块,定义了对委托实例的操作。逆变性不适用于匿名方法,必须指定和委托类型完全匹配的参数类型(在本例中是两个Circle类型)。
通过在匿名方法中加入return来获得返回值。.NET 2中很少有委托有返回值(因为多个委托形成委托链之后,前面的返回值会被后面的覆盖),但LINQ中大部分委托都有返回值(通过Func泛型委托)。
使用匿名方法的主要好处是:不需要为一个函数命名,尤其是那种只用一次的函数,或者很短很简单的函数。当你了解了lambda表达式之后,就会发现在linq中,到处都是lambda表达式,而里面其实都是匿名函数(即委托)。如果我们在频繁使用linq的过程中,每次都要在外部建立一个函数,那代码的体积将会大大增加。
另外匿名函数还有很重要的一点,就是自动形成闭包。匿名函数内定义的变量称为匿名函数的局部变量,和普通函数不同的是,匿名函数除了可以使用局部变量,传入的变量之外,还可以使用捕获变量。当外部的变量被匿名函数在函数方法中使用时,称为该变量被捕获(即它成为了一个捕获变量)。
捕获的是变量的实例而不是值,也就是说,在匿名函数内的捕获变量和外部的变量是同一个。当变量被捕获时,值类型的变量自动“升级”,变成一个密封类。创建委托实例不会导致执行。
捕获变量(Captured Variable)的作用
捕获变量可以方便我们在创建匿名方法(或委托)时,获得所需要的变量。例如如果你有一个整型的列表,并希望写一个匿名方法筛选出小于某数limit的另一个列表,此时如果没有捕获变量,在匿名方法中我们就只能硬编码Limit的值,或者使用原始的委托,将变量传入委托的目标方法。
static IEnumerable<int> Filter(List<int> aList, int limit)
{
//lambda表达式捕获外部变量Limit
return aList.Where(a => a < limit);
}
捕获变量的生存期
只要还有委托引用这个捕获变量,它就会一直存在。不管这个捕获变量是值类型还是引用类型,编译器会为其生成一个额外的类。
public delegate void MethodInvoker();
static void Main(string[] args)
{
MethodInvoker m = CreateDelegate();
//由于有委托引用a,a将会一直存在
//捕获变量a不再位于栈上,编译器将其视为一个额外的类
//CreateDelegate方法拥有对这个额外的类的一个实例的引用
//当委托被回收之前,不会回收这个额外的类
m();
} static MethodInvoker CreateDelegate()
{
int a = ;
MethodInvoker m = () =>
{
Console.WriteLine(a);
a++;
};
m();
return m;
}
打印出1和2。输出1是因为在调用CreateDelegate时,变量a是可用的。当CreateDelegate返回之后,调用m,a仍然是可用的,并没有随之消失。由于被捕获而形成闭包,a由一个栈上的值类型变成了引用类型。编译器生成了一个额外的密封类(名字是比较没有可读性的,例如c__DisplayClass1),它拥有一个成员a和一个方法,该方法内部的代码就是MethodInvoker中的代码。
CreateDelegate持有一个类型c__DisplayClass1的引用,所以它一直都能使用c__DisplayClass1中的成员a。
internal class Program
{
public delegate void MethodInvoker(); [CompilerGenerated]
private sealed class <>c__DisplayClass1
{
public int a; public void <CreateDelegate>b__0()
{
Console.WriteLine(this.a);
this.a++;
}
} private static void Main(string[] args)
{
Program.MethodInvoker methodInvoker = Program.CreateDelegate();
methodInvoker();
Console.ReadKey();
} private static Program.MethodInvoker CreateDelegate()
{
Program.<>c__DisplayClass1 <>c__DisplayClass = new Program.<>c__DisplayClass1();
<>c__DisplayClass.a = ;
Program.MethodInvoker methodInvoker = new Program.MethodInvoker(<>c__DisplayClass.<CreateDelegate>b__0);
methodInvoker();
return methodInvoker;
}
}
面试题:共享和非共享的捕获变量
在闭包和for循环一起使用时,如果多个委托捕捉到了同一个变量,则会有两种情况:捕捉到了同一个变量仅有的一个实例,和捕捉到同一个变量,但每个委托拥有自己的一个实例。
static void Main()
{
int copy;
List<Action> actions = new List<Action>();
for (int counter = ; counter < ; counter++)
{
//只有一个变量copy,它在循环开始之前已经创建
//所有的委托共享这个变量
copy = counter;
//创建委托时不会执行
actions.Add(() => Console.WriteLine(copy));
}
foreach (Action action in actions)
{
//执行委托时打印copy当前的值
//copy当前的值是9
action();
}
Console.ReadKey();
}
在这个例子中,捕获变量是copy,它只有一个实例(它的定义在外面,被捕获之后,自动升级为引用类型),所有委托共享这个实例。最后打印出10个9。
static void Main()
{
int copy;
List<Action> actions = new List<Action>();
for (int counter = ; counter < ; counter++)
{
copy = counter;
//现在有十个内部变量,每个委托有一个实例,不同委托拥有的实例值是不同的
//从而委托可以输出0-9
int copy1 = copy;
//创建委托时不会执行
actions.Add(() => Console.WriteLine(copy1));
}
foreach (Action action in actions)
{
//执行委托时打印copy1的值
action();
}
Console.ReadKey();
}
使用内部变量解决多个委托共享一个捕获变量实例的问题。下面的代码中,包含了上面所说的两种情况,可以思考下最终的打印结果:
static void Main(string[] args)
{
var list = new List<MethodInvoker>();
for (int index = ; index < ; index++)
{
var counter = index*; list.Add(delegate
{
Console.WriteLine("{0}, {1}", counter, index);
counter++;
});
} list[]();
list[]();
list[]();
list[]();
list[](); list[]();
list[]();
list[](); Console.ReadKey();
}
其中循环内部建立了五个MethodInvoker。它们共享一个变量index的实例,但各自有自己的变量counter的实例。所以最终打印的结果中,index的值将总是5,而counter的值则每次都不同。
最后额外执行了第一个委托三次,此时counter的值会使用第一次,第一个委托运行之后counter的值,故会打出1,之后打印2,3同理。如果你额外执行第二个委托一次,将会打出11。这充分说明了每个委托都持有一个counter的实例,且它们是相互独立的。而无论执行任意一个委托多少次,index的值都是5。
foreach循环中捕获变量的变化
在C# 5中,foreach循环的行为变了,不会再出现多个委托共享一个变量的行为。所以我们即使不声明内部变量,方法也会打印出令人容易理解的结果:
static void Main()
{
List<string> values = new List<string> {"a", "b", "c"};
var actions = new List<Action>();
foreach (string s in values)
{
//匿名方法捕获变量s
//类比for循环最后的10个9,s最后的值是c
//理论上会打印出三个c
//但在c# 5中,会打印出a,b,c
actions.Add(() => Console.WriteLine(s));
}
foreach (Action action in actions)
{
action();
}
Console.ReadKey();
}
但对于for语句,行为和之前一样,仍然需要注意捕获变量被共享的问题。
.NET面试题系列[12] - C# 3.0 LINQ的准备工作的更多相关文章
- java面试题系列12
1.面向对象的特征有哪些方面 a.抽象: 抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注意与当前目标有关的方面.抽象并不打算了解全部问题,而只是选择其中的一部分,暂时不用部分细节.抽象 ...
- C# 3.0 LINQ的准备工作
局部变量 隐式类型允许你用var修饰类型.用var修饰只是编译器方便我们进行编码,类型本身仍然是强类型的,所以当编译器无法推断出类型时(例如你初始化一个变量却没有为其赋值,或赋予null,此时就无法推 ...
- .NET面试题系列[0] - 写在前面
.NET面试题系列目录 .NET面试题系列[1] - .NET框架基础知识(1) .NET面试题系列[2] - .NET框架基础知识(2) .NET面试题系列[3] - C# 基础知识(1) .NET ...
- .NET面试题系列[15] - LINQ:性能
.NET面试题系列目录 当你使用LINQ to SQL时,请使用工具(比如LINQPad)查看系统生成的SQL语句,这会帮你发现问题可能发生在何处. 提升性能的小技巧 避免遍历整个序列 当我们仅需要一 ...
- 【转载】.NET面试题系列[0] - 写在前面
原文:.NET面试题系列[0] - 写在前面 索引: .NET框架基础知识[1] - .NET框架基础知识(1) http://www.cnblogs.com/haoyifei/p/5643689.h ...
- .NET技术面试题系列(1) 基础概念
这是.NET技术面试题系列第一篇,今天主要分享基础概念. 1.简述 private. protected. public.internal 修饰符的访问权限 private : 私有成员, 在类的内部 ...
- net必问的面试题系列之基本概念和语法
上个月离职了,这几天整理了一些常见的面试题,整理成一个系列给大家分享一下,机会是给有准备的人,面试造火箭,工作拧螺丝,不慌,共勉. 1.net必问的面试题系列之基本概念和语法 2.net必问的面试题系 ...
- .net必问的面试题系列之面向对象
上个月离职了,这几天整理了一些常见的面试题,整理成一个系列给大家分享一下,机会是给有准备的人,面试造火箭,工作拧螺丝,不慌,共勉. 1.net必问的面试题系列之基本概念和语法 2.net必问的面试题系 ...
- C# 刷遍 Leetcode 面试题系列连载(3): No.728 - 自除数
前文传送门: C#刷遍Leetcode面试题系列连载(1) - 入门与工具简介 C#刷遍Leetcode面试题系列连载(2): No.38 - 报数 系列教程索引 传送门:https://enjoy2 ...
随机推荐
- 【Java EE 学习 83 上】【SpringMVC】【基本使用方法】
一.SpringMVC框架概述 什么是SpringMVC?SpringMVC是一个和Struts2差不多的东西,他们的作用和性质几乎是相同的,甚至开发效率上也差不多,但是在运行效率上SpringMVC ...
- 【积累篇:他山之石,把玉攻】解决XP 系统 .Net Framework 4安装时出现严重错误 (0x80070643)
第一步: 1.开始——运行——输入cmd——回车——在打开的窗口中输入net stop WuAuServ 2.开始——运行——输入%windir% 3.在打开的 的窗口中有个文件夹叫SoftwareD ...
- local variable 'r' referenced before assignment
这个错误是说r在使用前没有定义 def cateToNum(c): if c == 'M PRO': r = 1 if c == 'F PRO': r = 2 if c == 'M PREMIER': ...
- asterisk简单命令
重启asterisk [root@EC2-V2 ~]# service asterisk restart 进入asterisk操作界面 [root@EC2-V2 ~]# asterisk -vvvr ...
- ring3硬件断点
4个断点寄存器DR0~DR3用来设置断点的线性地址. DR6为状态寄存器,DR7为控制寄存器. DR4和DR5保留.当CR4.DE==1时,访问DR4和DR5产生#UD异常:IF CR4.DE==0, ...
- Go语言 模板的使用(一)
使用Parse package main import ( "html/template" "net/http" ) func SayHello(w http. ...
- java 聊天猜拳机器人
2016-12-06本随笔记录第一次制作经过,感谢各位大神指导. 工具:eclipse;JAVA JDK; 语言:java 时间:2016.11.23 作者:潇洒鸿图 地址:http://www.cn ...
- iOS 链式编程探索(Masonry)
看了几篇关于链式编程的文章,还是理解的不透彻,我想这可能是因为我自己对block掌握的不熟练. 我已经明白了,所以,和大家分享一下我的理解!如有问题,麻烦大家指出! 直接看代码吧!关键的注释都有. 我 ...
- Python Turtle
之前对这个turtle这个模块不了解,觉得没什么意思,最近试了一下发现不错,来点最简单的.写的时候深刻感受到自己智商是个硬伤,算角度都算了半天... 图就不导了吧,懒癌晚期... import tur ...
- 页面加载完成后,触发事件——trigger()
<button id="btn">点击我</button> <div id="test"></div> 如果页面 ...