自从Lambda随.NET Framework3.5出现在.NET开发者眼前以来,它已经给我们带来了太多的欣喜。它优雅,对开发者更友好,能提高开发效率,天啊!它还有可能降低发生一些潜在错误的可能。LINQ包括ASP.NET MVC中的很多功能都是用Lambda实现的。我只能说自从用了Lambda,我腰也不酸了,腿也不疼了,手指也不抽筋了,就连写代码bug都少了。小伙伴们,你们今天用Lambda了么?但是你真的了解它么?今天我们就来好好的认识一下吧。
本文会介绍到一些Lambda的基础知识,然后会有一个小小的性能测试对比Lambda表达式和普通方法的性能,接着我们会通过IL来深入了解Lambda到底是什么,最后我们将用Lambda表达式来实现一些JavaScript里面比较常见的模式。
了解Lambda
在.NET 1.0的时候,大家都知道我们经常用到的是委托。有了委托呢,我们就可以像传递变量一样的传递方法。在一定程序上来讲,委托是一种强类型的托管的方法指针,曾经也一时被我们用的那叫一个广泛呀,但是总的来说委托使用起来还是有一些繁琐。来看看使用一个委托一共要以下几个步骤:
- 用delegate关键字创建一个委托,包括声明返回值和参数类型
- 使用的地方接收这个委托
- 创建这个委托的实例并指定一个返回值和参数类型匹配的方法传递过去
复杂吗?好吧,也许06年你说不复杂,但是现在,真的挺复杂的。
后来,幸运的是.NET 2.0为了们带来了泛型。于是我们有了泛型类,泛型方法,更重要的是泛型委托。最终 在.NET3.5的时候,我们Microsoft的兄弟们终于意识到其实我们只需要2个泛型委托(使用了重载)就可以覆盖99%的使用场景了。
- Action 没有输入参数和返回值的泛型委托
- Action<T1, …, T16> 可以接收1个到16个参数的无返回值泛型委托
- Func<T1, …, T16, Tout> 可以接收0到16个参数并且有返回值的泛型委托
这样我们就可以跳过上面的第一步了,不过第2步还是必须的,只是用Action或者Func替换了。别忘了在.NET2.0的时候我们还有匿名方法,虽然它没怎么流行起来,但是我们也给它 一个露脸的机会。
Func<double, double> square = delegate (double x) {
return x * x;
}
最后,终于轮到我们的Lambda优雅的登场了。
// 编译器不知道后面到底是什么玩意,所以我们这里不能用var关键字
Action dummyLambda = () => { Console.WriteLine("Hello World from a Lambda expression!"); }; // double y = square(25);
Func<double, double> square = x => x * x; // double z = product(9, 5);
Func<double, double, double> product = (x, y) => x * y; // printProduct(9, 5);
Action<double, double> printProduct = (x, y) => { Console.WriteLine(x * y); }; // var sum = dotProduct(new double[] { 1, 2, 3 }, new double[] { 4, 5, 6 });
Func<double[], double[], double> dotProduct = (x, y) =>
{
var dim = Math.Min(x.Length, y.Length);
var sum = 0.0;
for (var i = 0; i != dim; i++)
sum += x[i] + y[i];
return sum;
}; // var result = matrixVectorProductAsync(...);
Func<double, double, Task<double>> matrixVectorProductAsync = async (x, y) =>
{
var sum = 0.0;
/* do some stuff using await ... */
return sum;
};
从上面的代码中我们可以看出:
- 如果只有一个参数,不需要写()
- 如果只有一条执行语句,并且我们要返回它,就不需要{},并且不用写return
- Lambda可以异步执行,只要在前面加上async关键字即可
- Var关键字在大多数情况下都不能使用
当然,关于最后一条,以下这些情况下我们还是可以用var关键字的。原因很简单,我们告诉编译器,后面是个什么类型就可以了。
Func<double,double> square = (double x) => x * x; Func<string,int> stringLengthSquare = (string s) => s.Length * s.Length; Action<decimal,string> squareAndOutput = (decimal x, string s) =>
{
var sqz = x * x;
Console.WriteLine("Information by {0}: the square of {1} is {2}.", s, x, sqz);
};
现在,我们已经知道Lambda的一些基本用法了,如果仅仅就这些东西,那就不叫快乐的Lambda表达式了,让我们看看下面的代码。
var a = 5;
Func<int,int> multiplyWith = x => x * a;
var result1 = multiplyWith(10); //50
a = 10;
var result2 = multiplyWith(10); //100
是不是有一点感觉了?我们可以在Lambda表达式中用到外面的变量,没错,也就是传说中的闭包啦。
void DoSomeStuff()
{
var coeff = 10;
Func<int,int> compute = x => coeff * x;
Action modifier = () =>
{
coeff = 5;
}; var result1 = DoMoreStuff(compute); ModifyStuff(modifier); var result2 = DoMoreStuff(compute);
} int DoMoreStuff(Func<int,int> computer)
{
return computer(5);
} void ModifyStuff(Action modifier)
{
modifier();
}
在上面的代码中,DoSomeStuff方法里面的变量coeff实际是由外部方法ModifyStuff修改的,也就是说ModifyStuff这个方法拥有了访问DoSomeStuff里面一个局部变量的能力。它是如何做到的?我们马上会说的J。当然,这个变量作用域的问题也是在使用闭包时应该注意的地方,稍有不慎就有可能会引发你想不到的后果。看看下面这个你就知道了。
var buttons = new Button[10]; for (var i = 0; i < buttons.Length; i++)
{
var button = new Button();
button.Text = (i + 1) + ". Button - Click for Index!";
button.OnClick += (s, e) => { Messagebox.Show(i.ToString()); };
buttons[i] = button;
}
猜猜你点击这些按钮的结果是什么?是”1, 2, 3…”。但是,其实真正的结果是全部都显示10。为什么?不明觉历了吧?那么如果避免这种情况呢?
var button = new Button();
var index = i;
button.Text = (i + 1) + ". Button - Click for Index!";
button.OnClick += (s, e) => { Messagebox.Show(index.ToString()); };
buttons[i] = button;
其实做法很简单,就是在for的循环里面把当前的i保存下来,那么每一个表达式里面存储的值就不一样了。
接下来,我们整点高级的货,和Lambda息息相关的表达式(Expression)。为什么说什么息息相关,因为我们可以用一个Expression将一个Lambda保存起来。并且允许我们在运行时去解释这个Lambda表达式。来看一下下面简单的代码:
Expression<Func<MyModel, int>> expr = model => model.MyProperty;
var member = expr.Body as MemberExpression;
var propertyName = member.Expression.Member.Name;
这个的确是Expression最简单的用法之一,我们用expr存储了后面的表达式。编译器会为我们生成表达式树,在表达式树中包括了一个元数据像参数的类型,名称还有方法体等等。在LINQ TO SQL中就是通过这种方法将我们设置的条件通过where扩展方法传递给后面的LINQ Provider进行解释的,而LINQ Provider解释的过程实际上就是将表达式树转换成SQL语句的过程。
Lambda表达式的性能
关于Lambda性能的问题,我们首先可能会问它是比普通的方法快呢?还是慢呢?接下来我们就来一探究竟。首先我们通过一段代码来测试一下普通方法和Lambda表达 式之间的性能差异。
class StandardBenchmark : Benchmark
{
const int LENGTH = 100000;
static double[] A;
static double[] B; static void Init()
{
var r = new Random();
A = new double[LENGTH];
B = new double[LENGTH]; for (var i = 0; i < LENGTH; i++)
{
A[i] = r.NextDouble();
B[i] = r.NextDouble();
}
} static long LambdaBenchmark()
{
Func<double> Perform = () =>
{
var sum = 0.0; for (var i = 0; i < LENGTH; i++)
sum += A[i] * B[i]; return sum;
};
var iterations = new double[100];
var timing = new Stopwatch();
timing.Start(); for (var j = 0; j < iterations.Length; j++)
iterations[j] = Perform(); timing.Stop();
Console.WriteLine("Time for Lambda-Benchmark: \t {0}ms", timing.ElapsedMilliseconds);
return timing.ElapsedMilliseconds;
} static long NormalBenchmark()
{
var iterations = new double[100];
var timing = new Stopwatch();
timing.Start(); for (var j = 0; j < iterations.Length; j++)
iterations[j] = NormalPerform(); timing.Stop();
Console.WriteLine("Time for Normal-Benchmark: \t {0}ms", timing.ElapsedMilliseconds);
return timing.ElapsedMilliseconds;
} static double NormalPerform()
{
var sum = 0.0; for (var i = 0; i < LENGTH; i++)
sum += A[i] * B[i]; return sum;
}
}
}
代码很简单,我们通过执行同样的代码来比较,一个放在Lambda表达式里,一个放在普通的方法里面。通过4次测试得到如下结果:
Lambda Normal-Method
70ms 84ms
73ms 69ms
92ms 71ms
87ms 74ms
按理来说,Lambda应该是要比普通方法慢很小一点点的,但是不明白第一次的时候为什么Lambda会比普通方法还快一点。- -!不过通过这样的对比我想至少可以说明Lambda和普通方法之间的性能其实几乎是没有区别的。
那么Lambda在经过编译之后会变成什么样子呢?让LINQPad告诉你。
上图中的Lambda表达式是这样的:
Action<string> DoSomethingLambda = (s) =>
{
Console.WriteLine(s);// + local
};
对应的普通方法的写法是这样的:
void DoSomethingNormal(string s)
{
Console.WriteLine(s);
}
上面两段代码生成的IL代码呢?是这样地:
DoSomethingNormal:
IL_0000: nop
IL_0001: ldarg.1
IL_0002: call System.Console.WriteLine
IL_0007: nop
IL_0008: ret
<Main>b__0:
IL_0000: nop
IL_0001: ldarg.0
IL_0002: call System.Console.WriteLine
IL_0007: nop
IL_0008: ret
最大的不同就是方法的名称以及方法的使用而不是声明,声明实际上是一样的。通过上面的IL代码我们可以看出,这个表达式实际被编译器取了一个名称,同样被放在了当前的类里面。所以实际上,和我们调类里面的方法没有什么两样。下面这张图说明了这个编译的过程:
上面的代码中没有用到外部变量,接下来我们来看另外一个例子。
void Main()
{
int local = 5; Action<string> DoSomethingLambda = (s) => {
Console.WriteLine(s + local);
}; global = local; DoSomethingLambda("Test 1");
DoSomethingNormal("Test 2");
} int global; void DoSomethingNormal(string s)
{
Console.WriteLine(s + global);
}
这次的IL代码会有什么不同么?
IL_0000: newobj UserQuery+<>c__DisplayClass1..ctor
IL_0005: stloc.1
IL_0006: nop
IL_0007: ldloc.1
IL_0008: ldc.i4.5
IL_0009: stfld UserQuery+<>c__DisplayClass1.local
IL_000E: ldloc.1
IL_000F: ldftn UserQuery+<>c__DisplayClass1.<Main>b__0
IL_0015: newobj System.Action<System.String>..ctor
IL_001A: stloc.0
IL_001B: ldarg.0
IL_001C: ldloc.1
IL_001D: ldfld UserQuery+<>c__DisplayClass1.local
IL_0022: stfld UserQuery.global
IL_0027: ldloc.0
IL_0028: ldstr "Test 1"
IL_002D: callvirt System.Action<System.String>.Invoke
IL_0032: nop
IL_0033: ldarg.0
IL_0034: ldstr "Test 2"
IL_0039: call UserQuery.DoSomethingNormal
IL_003E: nop DoSomethingNormal:
IL_0000: nop
IL_0001: ldarg.1
IL_0002: ldarg.0
IL_0003: ldfld UserQuery.global
IL_0008: box System.Int32
IL_000D: call System.String.Concat
IL_0012: call System.Console.WriteLine
IL_0017: nop
IL_0018: ret <>c__DisplayClass1.<Main>b__0:
IL_0000: nop
IL_0001: ldarg.1
IL_0002: ldarg.0
IL_0003: ldfld UserQuery+<>c__DisplayClass1.local
IL_0008: box System.Int32
IL_000D: call System.String.Concat
IL_0012: call System.Console.WriteLine
IL_0017: nop
IL_0018: ret <>c__DisplayClass1..ctor:
IL_0000: ldarg.0
IL_0001: call System.Object..ctor
IL_0006: ret
你发现了吗?两个方法所编译出来的内容是一样的, DoSomtingNormal和<>c__DisplayClass1.<Main>b__0,它们里面的内容是一样的。但是最大的不一样,请注意了。当我们的Lambda表达式里面用到了外部变量的时候,编译器会为这个Lambda生成一个类,在这个类中包含了我们表达式方法。在使用这个Lambda表达式的地方呢,实际上是new了这个类的一个实例进行调用。这样的话,我们表达式里面的外部变量,也就是上面代码中用到的local实际上是以一个全局变量的身份存在于这个实例中的。
用Lambda表达式实现一些在JavaScript中流行的模式
说到JavaScript,最近几年真是风声水起。不光可以应用所有我们软件工程现存的一些设计模式,并且由于它的灵活性,还有一些由于JavaScript特性而产生的模式。比如说模块化,立即执行方法体等。.NET由于是强类型编译型的语言,灵活性自然不如JavaScript,但是这并不意味着JavaScript能做的事情.NET就不能做,下面我们就来实现一些JavaScript中好玩的写法。
回调模式
回调模式也并非JavaScript特有,其实在.NET1.0的时候,我们就可以用委托来实现回调了。但是今天我们要实现的回调可就不一样了。
void CreateTextBox()
{
var tb = new TextBox();
tb.IsReadOnly = true;
tb.Text = "Please wait ...";
DoSomeStuff(() => {
tb.Text = string.Empty;
tb.IsReadOnly = false;
});
} void DoSomeStuff(Action callback)
{
// Do some stuff - asynchronous would be helpful ...
callback();
}
上面的代码中,我们在DoSomeStuff完成之后,再做一些事情。这种写法在JavaScript中是很常见的,jQuery中的Ajax的oncompleted, onsuccess不就是这样实现的么?又或者LINQ扩展方法中的foreach不也是这样的么?
返回方法
我们在JavaScript中可以直接return一个方法,在.net中虽然不能直接返回方法,但是我们可以返回一个表达式。
Func<string, string> SayMyName(string language)
{
switch(language.ToLower())
{
case "fr":
return name => {
return "Je m'appelle " + name + ".";
};
case "de":
return name => {
return "Mein Name ist " + name + ".";
};
default:
return name => {
return "My name is " + name + ".";
};
}
} void Main()
{
var lang = "de";
//Get language - e.g. by current OS settings
var smn = SayMyName(lang);
var name = Console.ReadLine();
var sentence = smn(name);
Console.WriteLine(sentence);
}
是不是有一种策略模式的感觉?这还不够完美,这一堆的switch case看着就心烦,让我们用Dictionary<TKey,TValue>来简化它。来看看来面这货:
static class Translations
{
static readonly Dictionary<string, Func<string, string>> smnFunctions = new Dictionary<string, Func<string, string>>(); static Translations()
{
smnFunctions.Add("fr", name => "Je m'appelle " + name + ".");
smnFunctions.Add("de", name => "Mein Name ist " + name + ".");
smnFunctions.Add("en", name => "My name is " + name + ".");
} public static Func<string, string> GetSayMyName(string language)
{
//Check if the language is available has been omitted on purpose
return smnFunctions[language];
}
}
自定义型方法
自定义型方法在JavaScript中比较常见,主要实现思路是这个方法被设置成一个属性。在给这个属性附值,甚至执行过程中我们可以随时更改这个属性的指向,从而达到改变这个方法的目地。
class SomeClass
{
public Func<int> NextPrime
{
get;
private set;
} int prime; public SomeClass
{
NextPrime = () => {
prime = 2; NextPrime = () => {
// 这里可以加上 第二次和第二次以后执行NextPrive()的逻辑代码
return prime;
}; return prime;
}
}
}
上面的代码中当NextPrime第一次被调用的时候是2,与此同时,我们更改了NextPrime,我们可以把它指向另外的方法,和JavaScrtip的灵活性比起来也不差吧?如果你还不满意 ,那下面的代码应该能满足你。
Action<int> loopBody = i => {
if(i == 1000)
loopBody = //把loopBody指向别的方法 /* 前10000次执行下面的代码 */
}; for(int j = 0; j < 10000000; j++)
loopBody(j);
在调用的地方我们不用考虑太多,然后这个方法本身就具有调优性了。我们原来的做法可能是在判断i==1000之后直接写上相应的代码,那么和现在的把该方法指向另外一个方法有什么区别呢?
自执行方法
JavaScript 中的自执行方法有以下几个优势:
- 不会污染全局环境
- 保证自执行里面的方法只会被执行一次
- 解释完立即执行
在C#中我们也可以有自执行的方法:
(() => {
// Do Something here!
})();
上面的是没有参数的,如果你想要加入参数,也非常的简单:
((string s, int no) => {
// Do Something here!
})("Example", 8);
.NET4.5最闪的新功能是什么?async?这里也可以
await (async (string s, int no) => {
// 用Task异步执行这里的代码
})("Example", 8); // 异步Task执行完之后的代码
对象即时初始化
大家知道.NET为我们提供了匿名对象,这使用我们可以像在JavaScript里面一样随意的创建我们想要对象。但是别忘了,JavaScript里面可以不仅可以放入数据,还可以放入方法,.NET可以么?要相信,Microsoft不会让我们失望的。
//Create anonymous object
var person = new {
Name = "Jesse",
Age = 28,
Ask = (string question) => {
Console.WriteLine("The answer to `" + question + "` is certainly 42!");
}
}; //Execute function
person.Ask("Why are you doing this?");
但是如果你真的是运行这段代码,是会抛出异常的。问题就在这里,Lambda表达式是不允许赋值给匿名对象的。但是委托可以,所以在这里我们只需要告诉编译器,我是一个什么类型的委托即可。
var person = new {
Name = "Florian",
Age = 28,
Ask = (Action<string>)((string question) => {
Console.WriteLine("The answer to `" + question + "` is certainly 42!");
})
};
但是这里还有一个问题,如果我想在Ask方法里面去访问person的某一个属性,可以么?
var person = new
{
Name = "Jesse",
Age = 18,
Ask = ((Action<string>)((string question) => {
Console.WriteLine("The answer to '" + question + "' is certainly 20. My age is " + person.Age );
}))
};
结果是连编译都通不过,因为person在我们的Lambda表达式这里还是没有定义的,当然不允许使用了,但是在JavaScript里面是没有问题的,怎么办呢?.NET能行么?当然行,既然它要提前定义,我们就提前定义好了。
dynamic person = null;
person = new {
Name = "Jesse",
Age = 28,
Ask = (Action<string>)((string question) => {
Console.WriteLine("The answer to `" + question + "` is certainly 42! My age is " + person.Age + ".");
})
}; //Execute function
person.Ask("Why are you doing this?");
运行时分支
这个模式和自定义型方法有点类似,唯一的不同是它不是在定义自己,而是在定义别的方法。当然,只有当这个方法基于属性定义的时候才有这种实现的可能。
public Action AutoSave { get; private set; } public void ReadSettings(Settings settings)
{
/* Read some settings of the user */ if(settings.EnableAutoSave)
AutoSave = () => { /* Perform Auto Save */ };
else
AutoSave = () => { }; //Just do nothing!
}
可能有人会觉得这个没什么,但是仔细想想,你在外面只需要调用AutoSave就可以了,其它的都不用管。而这个AutoSave,也不用每次执行的时候都需要去检查配置文件了。
总结
Lambda表达式在最后编译之后实质是一个方法,而我们声明Lambda表达式呢实质上是以委托的形式传递的。当然我们还可以通过泛型表达式Expression来传递。通过Lambda表达式形成闭包,可以做很多事情,但是有一些用法现在还存在争议,本文只是做一个概述 :),如果有不妥,还请拍砖。谢谢支持 :)
还有更多Lambda表达式的新鲜玩法,请移步: 背后的故事之 - 快乐的Lambda表达式(二)
原文链接: http://www.codeproject.com/Articles/507985/Way-to-Lambda
背后的故事之 - 快乐的Lambda表达式(一)的更多相关文章
-
背后的故事之 - 快乐的Lambda表达式(二)
快乐的Lambda表达式 上一篇 背后的故事之 - 快乐的Lambda表达式(一)我们由浅入深的分析了一下Lambda表达式.知道了它和委托以及普通方法的区别,并且通过测试对比他们之间的性能,然后我们 ...
-
【转】背后的故事之 - 快乐的Lambda表达式(二)
快乐的Lambda表达式 上一篇 背后的故事之 - 快乐的Lambda表达式(一)我们由浅入深的分析了一下Lambda表达式.知道了它和委托以及普通方法的区别,并且通过测试对比他们之间的性能,然后我们 ...
-
【转】背后的故事之 - 快乐的Lambda表达式(一)
快乐的Lambda表达式(二) 自从Lambda随.NET Framework3.5出现在.NET开发者眼前以来,它已经给我们带来了太多的欣喜.它优雅,对开发者更友好,能提高开发效率,天啊!它还有可能 ...
-
快乐的Lambda表达式(二)
转载:http://www.cnblogs.com/jesse2013/p/happylambda-part2.html 快乐的Lambda表达式 上一篇 背后的故事之 - 快乐的Lambda表达式( ...
-
快乐的Lambda表达式(一)
转载:http://www.cnblogs.com/jesse2013/p/happylambda.html 原文出处: Florian Rappl 译文出处:Jesse Liu 自从Lambda ...
-
C#中的Lambda表达式和表达式树
在C# 2.0中,通过方法组转换和匿名方法,使委托的实现得到了极大的简化.但是,匿名方法仍然有些臃肿,而且当代码中充满了匿名方法的时候,可读性可能就会受到影响.C# 3.0中出现的Lambda表达式在 ...
-
为什么使用Lambda表达式(翻译版)
简介 如上图所示,绿色框中就是Lambda表达式,是可以执行的代码块.Lambda表达式是很多编程语言的特征,比如Lisp, Python, Scala等. 但是对于java,在8以后才支持这种写法. ...
-
18、(番外)匿名方法+lambda表达式
概念了解: 1.什么是匿名委托(匿名方法的简单介绍.为什么要用匿名方法) 2.匿名方法的[拉姆达表达式]方法定义 3.匿名方法的调用(匿名方法的参数传递.使用过程中需要注意什么) 什么是匿名方法? 匿 ...
-
匿名方法和Lambda表达式
匿名方法本质上是一传递给委托的代码块,是使用委托的另一种方法. 规则: 1.匿名方法中不能使用跳转语句跳至次匿名方法的外部,反之亦然:匿名方法外部的跳转语句也不能跳转到匿名方法的内部: 2.在匿名方法 ...
随机推荐
-
python3 文件增删查
#Author by Andy#_*_ coding:utf-8 _*_import sysimport timePath="E:\my python study\\files\haprox ...
-
翻译:WebApi 认证--用户认证Oauth解析
The Web API v2用户认证模板提供了流行的应用用户认证场景,如.使用本地帐号的用户名密码认账 (包括创建用户.设置和修改密码)以及使用第三方的认证方式,如facebook,googl ...
-
嵌入式css样式,写在当前的文件中
现在有一任务,把下面的“超酷的互联网”.“服务及时贴心”.“有趣易学”这三个短词文字字号修改为18px. 如果用内联式css样式的方法进行设置将是一件很头疼的事情(为每一个<span>标签 ...
-
OFBiz中根据店铺获取产品可用库存的方法
1.[ProductStoreFacility]获得店铺绑定的仓库列表 2.遍历仓库,调用[getInventoryAvailableByFacility],传入[facilityId : facil ...
-
《精通CSS》——个人总结
[属性选择器] 属性选择器可以根据某个属性是否存在或属性的值来寻找元素. 只有在规定了 !DOCTYPE 时,IE7 和 IE8 才支持属性选择器.在 IE6 及更低的版本中,不支持属性选择. 事例: ...
-
java虚拟机学习-JVM调优总结-基本垃圾回收算法(7)
可以从不同的的角度去划分垃圾回收算法: 1.按照基本回收策略分 引用计数(Reference Counting): 比较古老的回收算法.原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计 ...
-
Akka(25): Stream:对接外部系统-Integration
在现实应用中akka-stream往往需要集成其它的外部系统形成完整的应用.这些外部系统可能是akka系列系统或者其它类型的系统.所以,akka-stream必须提供一些函数和方法来实现与各种不同类型 ...
-
QtCore是Qt的精髓(包括五大模块:元对象系统,属性系统,对象模型,对象树,信号槽)
作者:小豆君的干货铺链接:https://www.zhihu.com/question/27040542/answer/218384474来源:知乎著作权归作者所有.商业转载请联系作者获得授权,非商业 ...
-
【OpenCV】解析OpenCV中copyMakerBorder函数
Use the OpenCV function :copy_make_border:`copyMakeBorder <>` to set the borders (extra paddin ...
-
02-SSH综合案例:需求分析(后台)
1.1.7 用户模块:(后台) 不用添加了,添加的话在前台就注册了. 查询所有用户: 修改用户信息: 删除用户信息: 1.1.8 一级分类:(后台) 主要都还是增删改查的操作 查询所有一级分类: ...