C# 获得了模式匹配、互斥联合、元组和范围
好吧,不是字面意义上的。增强版 C# 支持模式匹配、ADT 和元组,因此普通 C# 也通过传递性获得了所有这些功能。
引言
本文将教你如何使用开源增强版 C# + LeMP 项目的一些“函数式”特性:模式匹配、代数数据类型、元组和数值范围。要下载 Visual Studio 扩展或了解更多关于 LeMP 的信息,请访问 LeMP 主页。
模式匹配!
有一种代码模式偶尔会出现:“从某处获取一个对象,查看它是否是 X 类型,如果是,则获取/查询其属性”。这会很快变得很繁琐。
var obj = connection.DownloadNextObject();
if (obj is StatusReport)
{
StatusReport report = (StatusReport)obj;
if (report.IsValid) {
SaveReport(report);
}
}
else if (obj is DataPacket)
{
DataPacket packet = (DataPacket)obj;
DoStuffWith(packet);
}
我敢打赌你写过这样的代码。你们中的一些人经常写这种风格的代码。但现在 LeMP 有了一个处理这类模式的快捷方式:它叫做 match
。它就像 switch
,但用于“模式匹配”。使用 match
,上面的代码将变成简单的
match (connection.DownloadNextObject()) {
case $report is StatusReport(IsValid: true):
SaveReport(report);
case $packet is DataPacket:
DoStuffWith(packet);
}
很简单,对吧?LeMP 会将其翻译成 C# 代码,例如
do {
var tmp_1 = connection.DownloadNextObject();
if (tmp_1 is StatusReport) {
StatusReport report = (StatusReport) tmp_1;
if (true.Equals(report.IsValid)) {
SaveReport(report);
break;
}
}
if (tmp_1 is DataPacket) {
DataPacket packet = (DataPacket) tmp_1;
DoStuffWith(packet);
break;
}
} while (false);
match
允许 case 块使用 break
来退出每个分支,但与 switch
不同,它不强制要求 break
语句。match
将其输出包装在 do...while(false)
中,以防你的 case
块包含 break
语句(并且,它会在你没有时添加一个 break
)。请注意,这里的逻辑与原始 if-else
代码不完全相同:match
的行为就像一个大的“if-else”链,所以如果第一个 case
不匹配,它总是会尝试第二个。所以,如果它是一个 StatusReport
但 IsValid
是 false,它会继续检查它是否是 DataPacket
(这听起来可能很蠢,但请仔细想想:对象有可能同时是 StatusReport
和 DataPacket
。如果这是不可能的,也许幸运的话编译器会对其进行优化。)
match
还有更多功能,我们稍后会讨论。但首先,来自其赞助商的一句话
代数数据类型!
许多语言提供了由一系列替代项组成的数据类型,例如 Haskell 中一个简单的 二叉搜索树 表示
data BinaryTree t = Leaf t | Node t (BinaryTree t) (BinaryTree t)
这表示 BinaryTree
类型的值是泛型(t
是类型参数,就像 C# 中的 <T>
一样),BinaryTree
值是*要么*是 Leaf
(包含一个类型为 t
的单个值),*要么*是 Node
(包含一个类型为 t
的值以及两个 BinaryTree t
类型的值)。Haskell 人称之为代数数据类型。
代数数据类型(ADT)可能有一个听起来很蠢的名字,但它们也以另一个名字为人所知:互斥联合。
使用 LeMP,你可以使用 alt class
来编写 ADT。你会发现 C# ADT 的工作方式与其他语言中的互斥联合不同;由于其更高的灵活性,它们可能不应该被称为“互斥联合”。但无论你怎么称呼它们,它们都非常有用,尤其是在生产力和简洁代码是你的首要任务时。
在 LeMP 下,ADT 被称为 alt class
,它看起来像这样
public abstract alt class BinaryTree<T> where T: IComparable<T>
{
alt Leaf<T>(T Value);
alt Node(T Value, BinaryTree<T> Left, BinaryTree<T> Right);
// (you could also have written `Node<T>`)
}
事实上,这不过是一种非常快速生成不可变类型类层次结构的方法。生成的代码相当庞大
public abstract class BinaryTree<T> where T: IComparable<T> { public BinaryTree() { } } class Leaf<T> : BinaryTree<T> where T: IComparable<T> { public Leaf(T Value) { this.Value = Value; } public T Value { get; private set; } public Leaf<T> WithValue(T newValue) { return new Leaf<T>(newValue); } [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public T Item1 { get { return Value; } } } static partial class Leaf { public static Leaf<T> New<T>(T Value) where T: IComparable<T> { return new Leaf<T>(Value); } } class Node<T> : BinaryTree<T> where T: IComparable<T> { public Node(T Value, BinaryTree<T> Left, BinaryTree<T> Right) { this.Value = Value; this.Left = Left; this.Right = Right; } public T Value { get; private set; } public BinaryTree<T> Left { get; private set; } public BinaryTree<T> Right { get; private set; } public Node<T> WithValue(T newValue) { return new Node<T>(newValue, Left, Right); } public Node<T> WithLeft(BinaryTree<T> newValue) { return new Node<T>(Value, newValue, Right); } public Node<T> WithRight(BinaryTree<T> newValue) { return new Node<T>(Value, Left, newValue); } // Additional code hidden to spare your eyes } static partial class Node { public static Node<T> New<T>(T Value, BinaryTree<T> Left, BinaryTree<T> Right) where T: IComparable<T> { return new Node<T>(Value, Left, Right); } }
正如你所见,有大量的辅助代码可以使这些类型易于使用。
首先,你不仅会得到一个 Leaf<T>
和 Node<T>
类,还会得到没有类型参数的 Leaf
和 Node
类。这些允许你创建叶子和节点而无需提及类型 T
void TreeOfThree()
{
var tree = Node.New(42, Leaf.New(17), Leaf.New(99));
}
注意:仅当 alt
使用类型参数时,才会创建 New
方法。
你可以使用相应的“With
”方法“修改”ADT 的单个属性,如下所示
Node<T> node = Node.New(42, Leaf.New(17), Leaf.New(99));
// Use `WithRight` to change the right child to the leaf `101`:
node = node.WithRight(Leaf.New(101));
当然,你可以使用 match
来了解你的 ADT。例如,如果二叉树已排序,则可以这样搜索
public static bool Contains<T>(BinaryTree<T> tree, T item)
{
T value;
match (tree) {
case is Leaf<T>($value):
return Compare(value, item) == 0;
case is Node<T>($value, $left, $right):
int cmp = Compare(item, value);
if (cmp < 0)
return left != null && Contains(left, item);
else if (cmp > 0)
return right != null && Contains(right, item);
else
return true;
}
}
internal static int Compare<T>(T a, T b) where T:IComparable<T>
{ // It's null's fault that this method exists.
if (a != null)
return a.CompareTo(b);
else if (b != null)
return -a.CompareTo(a);
else
return 0;
}
请注意,此代码显示的是 Leaf<T>($value)
而不是 Leaf<T>(Value: $value)
,同样,case is Node<T>
没有提及 Value
、Left
或 Right
。如果你省略属性名称,match
将“按位置”从 Item1
、Item2
等读取项,这就是为什么生成的 Leaf<T>
具有 public T Item1
属性,该属性被标记为 EditorBrowsableState.Never
以在 IntelliSense 中隐藏它。
但是等等,还有更多!事实上,alt class
可以比其他支持 ADT 的语言中的 ADT 做更多的事情,因为它“接受”其作为类层次结构的身份,而不是伪装成传统的数学互斥联合:*它具有与普通类层次结构相同的能力*。
首先,请注意 Leaf
和 Node
都具有 Value
属性。我们可以将公共属性移到基类中,如下所示
public partial abstract alt class BinaryTree<T> where T: IComparable<T>
{
alt this(T Value);
alt Leaf();
alt Node(BinaryTree<T> Left, BinaryTree<T> Right);
}
alt this
提供了一种向基类添加数据的方法。此外,如果给它一个 { 花括号 }
主体,它就变成了构造函数中的代码。
现在,由于 Leaf()
除了基类包含的数据之外不包含任何额外数据,我们可以进一步完全消除它,同时从 BinaryTree<T>
中删除 abstract
属性
public alt class BinaryTree<T> where T: IComparable<T>
{
alt this(T Value);
alt Node(BinaryTree<T> Left, BinaryTree<T> Right);
}
这确实有一个缺点:叶子节点是用 BinaryTree.New
而不是 Leaf.New
创建的。但它仍然有效。
如果我们想确保 Node
不会用两个 null
子节点初始化,我们可以添加验证代码。这是通过向 Node
添加一个主体来完成的,该主体被视为类主体,然后添加一个名为 alt this
的“构造函数”
public alt class BinaryTree<T> where T: IComparable<T>
{
alt this(T Value);
alt Node(BinaryTree<T> Left, BinaryTree<T> Right)
{
public alt this() {
if (Left == null) throw new ArgumentNullException("Left");
if (Right == null) throw new ArgumentNullException("Right");
}
}
}
注意:诚然,这里有些奇怪:新的 this
构造函数有自己的参数列表,即使 Node
已经有一个了。正如你从第一个构造函数(alt this(T Value)
)中所见,alt class
中的构造函数参数会创建新属性。因此,你可以在 Node
列表或内部 this
列表中放置新属性;建议不要同时使用两者。
另外,与其使用 match
,不如将 Contains
方法实现为虚方法。
public alt class BinaryTree<T> where T: IComparable<T>
{
alt this(T Value);
alt Node(BinaryTree<T> Left, BinaryTree<T> Right)
{
public alt this() {
if (Left == null && Right == null) throw new ArgumentNullException("Both children");
}
public override bool Contains(T item)
{
int cmp = Compare(item, Value);
if (cmp < 0)
return Left != null && Left.Contains(item);
else if (cmp > 0)
return Right != null && Right.Contains(item);
else
return true;
}
}
public virtual bool Contains(T item)
{
return Compare(Value, item) == 0;
}
internal static int Compare(T a, T b)
{ // It's null's fault that this method exists.
if (a != null)
return a.CompareTo(b);
else if (b != null)
return -a.CompareTo(a);
else
return 0;
}
}
你还可以定义一个多层类层次结构。在下面的示例中,MyTuple<T1,T2>
派生自 MyTuple<T1>
,而 MyTuple<T1,T2,T3>
派生自 MyTuple<T1,T2>
public alt class MyTuple<T1> {
public alt this(T1 Item1);
public alt MyTuple<T1,T2>(T2 Item2) {
public alt MyTuple<T1,T2,T3>(T3 Item3) { }
}
}
更复杂的例子是 GUI 小部件信息的树
public abstract alt class Widget {
alt this(Rectangle Location) {
if (Location == null) throw new ArgumentNullException("Location");
}
alt Button(string Text) { }
alt TextBox(string Text) { }
abstract alt StringListWidget(string[] subItems) {
alt ComboBox();
alt ListBox();
}
public abstract alt Container() {
alt TabControl(TabPage[] Children);
alt Panel(Widget[] Children) {
alt TabPage(string Title);
}
}
}
请记住,嵌套的 alt
不是嵌套类;在输出中,每个新类都被提升到“顶层”。这意味着,例如,你应该写 new ComboBox(location, subItems)
,而不是 new Widget.StringListWidget.ComboBox(location, subItems)
。
最后,你可以使用 alt class
来生成只有一个“case”的不可变类,如下所示
public abstract alt class Rectangle {
alt this(int X, int Y, int Width, int Height);
}
注意:截至撰写本文时,alt class
不支持非公共构造函数。
好的,我想我们已经涵盖了所有内容!希望你喜欢。
元组
增强版 C# 支持元组,例如
var pair = (12, "twelve");
这变成了
var pair = Tuple.Create(12, "twelve");
注意:Tuple
是 .NET 框架的标准类。
你也可以解构元组,如下所示
(var num, var str) = pair;
输出
var num = pair.Item1; var str = pair.Item2;
由于 ADT 具有名为 Item1
、Item2
等的属性,因此你可以像这样解构它们
(var x, var y, var w, var h) = new Rectangle(10, 10, 600, 400);
输出
var tmp_0 = new Rectangle(10, 10, 600, 400); var x = tmp_0.Item1; var y = tmp_0.Item2; var w = tmp_0.Item3; var h = tmp_0.Item4;
然而,EC# 不帮助你编写元组*类型*。例如,要从函数返回 (5,"five")
,返回类型是 Tuple<int, string>
- 没有快捷方式。
更多模式匹配
使用元组
match
也支持元组和其他具有 Item1
、Item2
等的对象。
match (rect) {
case ($x, $y, $w, $h):
Console.WriteLine("("+x+","+y+","+w+","+h+")");
}
但这与上面显示的元组解构没有区别。它完全绕过了类型检查;代码只是
do { var x = rect.Item1; var y = rect.Item2; var w = rect.Item3; var h = rect.Item4; Console.WriteLine("(" + x + "," + y + "," + w + "," + h + ")"); break; } while(false);
这意味着如果 rect
本身类型错误(例如,它是 3 个而不是 4 个的元组),你将收到 C# 编译器的错误。记住,你必须使用 is
来检查数据类型
match (rect) {
case is Rectangle($x, $y, $w, $h):
Console.WriteLine("("+x+","+y+","+w+","+h+")");
}
不过,一旦你知道了类型,使用元组语法通常很有用。例如,在使用 is Button
后,我们知道我们有一个 Button
小部件,我们知道第一个组件是一个 Rectangle
,所以我们可以使用元组语法来解构它,如下所示
void DrawIfButton(Graphics g, Widget widget) {
match(widget) {
case is Button(($x, $y, $width, $height), $text):
// TODO: draw the button
}
}
简单匹配
模式匹配还能做什么?例如,你可以进行*相等性测试*和*范围测试*,如下所示
static void FavoriteNumberGame()
{
Console.Write("What's your favorite number? ");
match(int.Parse(Console.ReadLine())) {
case 7, 777: Console.WriteLine("You lucky bastard!");
case 5, 10: Console.WriteLine("I have that many fingers too!");
case 0, 1: Console.WriteLine("What? Nobody picks that!");
case 2, 3: Console.WriteLine("Yeah, I guess you deal with those a lot.");
case 12: Console.WriteLine("I prefer a baker's dozen.");
case 666, 13: Console.WriteLine("Isn't that bad luck though?");
case 1..<10: Console.WriteLine("Kind of boring, don't you think?");
case 11, 13, 17, 19, 23, 29: Console.WriteLine("A prime choice.");
case 10...99: Console.WriteLine("I have to admit... it has two digits.");
case _...-1: Console.WriteLine("Oh, don't be so negative.");
default: Console.WriteLine("What are you, high? Like that number?");
}
}
这个例子说明了几个问题。
单次评估:如你所料,match
会创建一个临时变量,因此 Console.ReadLine()
只会被调用一次。
优先级顺序:较早的 case 在较低的 case 之前进行测试,因此 5
匹配 case 5, 10
而不是 case 1..<10
。
相等性测试:你不仅可以使用字面量,如 case 5
,还可以使用表达式,如 case (x + y):
。在输出中,case 5
变成 if (5.Equals(matchExpr))
。为什么使用这种特定的相等性测试?考虑替代方案
if (matchExpr == 5)
在较少的情况下有效。特别是,考虑match((object)5) {case 5:}
。5.Equals((object)5)
返回 true,但(object)5 == 5
是一个编译时错误。因此Equals
更灵活。if (object.Equals(matchExpr, 5))
会涉及装箱和动态向下转换。if (matchExpr.Equals(5))
在matchExpr
为 null 的情况下会导致NullReferenceException
。
只有当你写 case null
时,形式才会改变;这会变成 if (matchExpr == null)
。注意:除了允许的字面量 null 之外,确保 case 本身不会评估为 null 是你的责任。出于性能和认识论的原因,当你使用非字面量表达式(如 case X
)时,match
不会检查 X
本身是否为 null(实际上,它无法判断 X
是否为可空类型)。
默认:与 switch
一样,match
可以有一个 default:
,但它必须放在最后。它等同于 case _:
。
每个 case
的多个模式:增强版 C# 允许 case
有多个*单独*的 case,用逗号分隔,例如 1..10, 20, 30
。不幸的是,在将代码翻译成普通 C# 时,两个单独的模式很难指向同一个*处理程序*,因此输出会复制处理程序。例如,上面的第一个 case
被翻译为
if (7.Equals(tmp_0)) { Console.WriteLine("You lucky bastard!"); break; } if (777.Equals(tmp_0)) { Console.WriteLine("You lucky bastard!"); break; }
在这个特定的例子中,*可以*避免复制 Console.WriteLine
语句,但在大多数非平凡的模式中*不行*(至少在 LeMP 不具备的分析能力下不行),所以 match
根本不尝试。因此,如果可能,请避免在带有多个模式的 case
中编写大代码块。
范围运算符:增强版 C# 定义了名为 ..<
和 ...
的二元和一元运算符,以及一个名为 in
的二元运算符,该运算符旨在测试值是否包含在集合中。- ..<
是独占范围运算符:它意味着你希望排除范围右侧的数字。case 1..<10
被翻译为类似于 if (tmp_0.IsInRangeExcludeHi(1, 10))
。IsInRangeExcludeHi
应该是扩展方法,要么是你自己定义的,要么是 Loyc.Essentials.dll 中的一个扩展方法。这个运算符实际上有两个名称:你可以写 1..10
,如 Rust 中所用,或者 1..<10
,如 Swift 中所用。词法分析器将它们视为同一个运算符(名为 ..
)。- ...
是包含范围运算符:它意味着你希望包含范围右侧的数字。例如,case 10...99
被翻译为类似于 if (tmp_0.IsInRange(10, 99))
。(这个运算符在 Rust 和 Swift 中也被称为三个点。)
你可以使用下划线来表示一个开放范围,例如 case _..-1
。..<
和 ...
也作为一元运算符存在,所以你可以写 ...-1
。但是,没有相应的后缀运算符:你必须写 100..._
,而不是 100...
。所有这些都被翻译成相应的二元运算符(例如,_...9
变成 matchExpr <= 9
)。
一个花哨的例子
这是一个更复杂的例子,展示了 match
的大部分功能
match (obj) {
case is Shape(ShapeType.Circle, $size, Location: $p is Point<int>($x, $y) && x > y):
Circle(size, x, y);
}
写这篇文章时,我曾试图解释这是做什么的,但解释太长了,我决定最好只展示输出代码
if (obj is Shape) { Shape tmp_0 = (Shape) obj; if (ShapeType.Circle.Equals(tmp_0.Item1)) { var size = tmp_0.Item2; var tmp_1 = tmp_0.Location; if (tmp_1 is Point<int>) { Point<int> p = (Point<int>) tmp_1; var x = p.Item1; var y = p.Item2; if (x > y) { Circle(size, x, y); break; } } } }
你可以在这里看到更多功能
一元和二元“is”运算符:is Type
是一个添加到增强版 C# 中的新运算符,专门用于支持模式匹配。它的意思是“检查 match_expression is Type
,如果是,则向下转换为 Type
并创建一个临时变量来保存结果”。is
的二元版本允许在左侧有几种不同的情况
$newVar is Type
创建一个名为Type newVar
的新变量来保存向下转换的值ref var is Type
将一个*现有*变量var
设置为向下转换的值low..hi is Type
将向下转换的值保存在一个临时变量中,然后检查它是否在指定的范围内otherExpr is Type
(其中otherExpr
不匹配以上任何模式)将向下转换的值保存在一个临时变量中,然后检查otherExpr
是否等于它。
括号内的子模式:在模式的 is
部分之后,你可以在括号中编写“内部模式”或“子模式”。例如,在 case A(B(C), D)
中,A
具有子模式 B(C)
和 D
,而 D
是 B
的子模式。每个子模式的处理方式与最外层模式相同,只是子模式可以指定属性名称(例如 Location:
),而外层模式不能。
位置和命名属性:在此示例中,Shape
的前两个组件被视为“位置”属性,而第三个组件是“命名”属性(其名称为 Location
)。命名属性由标识符后跟冒号 (:
) 组成,例如 Location:
。如果你不提供名称,match
将使用编号属性(Item1
、Item2
等)。因此,在此示例中,Shape.Item1
属性与子模式 ShapeType.Circle
进行匹配,而 Shape.Item2
与 $size
进行匹配。
请注意,你只能命名简单属性,而不能命名嵌套属性、方法或索引器属性。例如,你可能会尝试写 case is Foo(Bar(): 777)
来找出 Foo.Bar()
方法是否返回 777
,但这不被允许,因为这是语法错误。但是,你可以写 case $foo is Foo && foo.Bar() == 777
来代替。
变量绑定:使用 $
运算符创建新变量并将其分配给对象的一部分值。在这种情况下,新的 size
变量被分配给 obj
的 Shape.Item2
属性,新的 p
变量被分配给 Shape.Location
,依此类推。
附加条件:你可以在主模式或子模式上使用 &&
运算符添加附加条件,例如给定
case is Size(Width: $w, Height: $h && h > 100) && w > h:
DoSomethingWith(w, h);
输出类似于
if (obj is Size) { Size tmp_2 = (Size) obj; var w = tmp_2.Width; var h = tmp_2.Height; if (h > 100 && w > h) { DoSomethingWith(w, h); break; } }
大致从左到右求值:模式的求值大致是从左到右,但如果你使用的是二元 is
条件,例如 $x is Type
,则右侧的类型测试(显然)在左侧的测试或绑定之前运行。
使用“in”运算符的 case
前面你看到你可以写 case lo..hi
来找出值是否在范围内。如果你想将范围测试与变量绑定、相等性测试或子模式匹配结合起来,可以使用 in
运算符。这里有一些例子
match(value) {
// Is value a double between 0 and 1 ?
case $newVar is double in 0.0...1.0:
ZeroToOne(newVar);
// This one is tricky! It requires that `coefficient.Equals(value) && value in 0...1`
case coefficient in 0.0...1.0:
ZeroToOne(newVar);
// Due to the precedence rules of EC#, if you combine `in` with
// subpatterns, the subpatterns must come before `in`.
case _ is Point(X: $x, Y: $y) in polygon:
CollisionDetected(x, y);
// However, when you add conditions with `&&`, they still come last.
case is Size(Width: $w, Height: $h) in acceptableSizes && w > h:
SizeIsOK(w, h);
}
使用“ref
”为现有变量赋值
你可以使用 ref variable
而不是 $variable
来将值赋给现有变量,而不是创建一个新变量。为了与上一篇文章中引入的 matchCode
宏保持一致,也接受 $(ref variable)
。
原理
这让我想起来:你认为为什么在 case
中创建新变量的语法是 $x
而不是,比如说,var x
?部分原因是 var x
通常不被解析器允许,而且 matchCode
宏也使用 $x
语法,并且 var x
在 matchCode
的上下文中通常甚至*不可能*。
问你为什么必须写 case $x is Foo
而不是简单地写 case x is Foo
,这也是合理的。事实上,起初我确实支持后一种语法(因为 Rust 的工作方式类似),但后来我决定放弃支持。有三个原因
$x
更容易被注意到,所以阅读代码的人可以更容易地看到创建x
的地方。$x
与matchCode
的语法一致。- 如果你写
777 is Foo
或Foo.Bar is Foo
,左侧将被解释为相等性测试。因此,x is Foo
将是一个特例,而且x is Foo
和x.y is Foo
的作用完全不同,这可能令人惊讶。
独立范围和“in
”运算符
..<
、...
和 in
运算符不限于 match
。你可以在普通表达式中使用它们,如下所示
if (!(index in 0..list.Count))
throw new ArgumentOutOfRangeException("index");
如前所述,x in lo..<hi
模式被转换为 x.IsInRangeExcludeHi(lo, hi)
,而 x in lo...hi
模式被转换为 x.IsInRange(lo, hi)
。你也可以单独使用 in
,或者单独使用范围运算符
var range = 0..list.Count;
if (!(index in range))
throw new ArgumentOutOfRangeException("index");
这被翻译为
var range = Range.ExcludeHi(0, list.Count); if (!range.Contains(index)) throw new ArgumentOutOfRangeException("index");
Loyc.Essentials.dll 包含此处显示的所有方法;IsInRangeExcludeHi
是 Loyc.Range
类中的一个扩展方法。虽然 Range.ExcludeHi
在 Loyc
命名空间中,但它返回一个 Loyc.Collections.NumRange<Num,Math>
类型的变量,其中 Num
是一个数字类型(如 int
),而 Math
是一个辅助类型,允许 NumRange
对该数字类型执行算术运算(这是必需的,因为 .NET 没有为内置类型定义数学接口)。值得注意的是 NumRange
实现 IReadOnlyList<Num>
,因此你可以在 foreach
循环和 LINQ 表达式中使用它。
由于表达式 x in range
只是调用 range.Contains(x)
,因此它与标准集合类型兼容。
总结
我想这就是全部了。希望这些功能能让你更有效率。享受!
要了解更多信息或下载 LeMP,请访问 LeMP 主页。