距离上一篇文章已经过去了挺久的,很长时间没有写GH基础部分的内容了,原因其一是本职工作太忙了,进度也有些落后,白天工作累成马,回家只想躺着;其二则是感觉GH基础系列基本上也介绍得差不多了,电池二次开发的一些基本操作(功能/外观)都介绍得差不多了,再加上前几期写的数据类型,这基本上就囊括了所有二次开发需要用到的内容。
不过,理论知识和实践总归是有一些差距的,在CSDN上还是会偶尔收到私信问一些细节问题的二开爱好者们。这些问题确实是做电池二次开发的时候遇到的,但它们本身可能与电池的二次开发没有关系:其中有一部分是C#代码本身的编程逻辑问题,还有一部分是有关于Rhino的SDK的问题,另外还有一些关于Windows Form、WPF等前端框架的问题。有些问题会被反复地问到,所以笔者决定还是多多将大家遇到的有共性的问题也做一系列解答,方便读者在还没有遇到这些类似的问题的时候,能够有那么一点点印象,当真正碰到这些问题的时候,能够找对解决问题的方向,少走一些弯路。
这篇文章要讲的问题是有关于右键菜单的菜单项的回调函数的问题,这个问题的根源是来自
C#代码编程本身,也是十分具有迷惑性,相信没有完整看过C#基础知识直接上手二开的爱好者们在第一次遇到这个问题的时候肯定十分地困惑。下面就来看具体问题吧。
近期经常收到一个问题 —— “为什么我添加的右键菜单项有Bug?” “我用了一个for循环去添加菜单项,想一次性添加x个菜单项,并在菜单被点击的时候执行 xxxx,但是结果总是不变,而且不对,这是不是GH出Bug了?”
相信有不少二开的小伙伴会做这样的一个需求:需要一个电池,这个电池需要依照情况输出若干个确定的值,具体输出哪个值需要用右键菜单来指定。类似于 ValueList
电池那样可以通过选择来输出若干个指定值其中的一个。
要实现这个功能,最简单直观的就是在电池中加入一个属性叫 ComponentPropertyValue
,然后在右键菜单中改变它,并调用 ExpireSolution
,同时,SolveInstance
函数中依照这个属性来赋值:
private int ComponentPropertyValue { get; set; }
protected override void AppendAdditionalComponentMenuItems(ToolStripDropDown menu)
{
menu.Items.Add(new ToolStripMenuItem("1", null,
(o, e) => { ComponentPropertyValue = 1; this.ExpireSolution(true); }));
menu.Items.Add(new ToolStripMenuItem("2", null,
(o, e) => { ComponentPropertyValue = 2; this.ExpireSolution(true); }));
menu.Items.Add(new ToolStripMenuItem("3", null,
(o, e) => { ComponentPropertyValue = 3; this.ExpireSolution(true); }));
menu.Items.Add(new ToolStripMenuItem("4", null,
(o, e) => { ComponentPropertyValue = 4; this.ExpireSolution(true); }));
menu.Items.Add(new ToolStripMenuItem("5", null,
(o, e) => { ComponentPropertyValue = 5; this.ExpireSolution(true); }));
}
protected override void SolveInstance(IGH_DataAccess DA)
{
// 这里为了举例方便设置为该数值的平方
// 实际可能会有较为复杂的运算逻辑
DA.SetData(0, ComponentPropertyValue * ComponentPropertyValue);
}
显然,作为一个写过一段时间代码的正常人,应该能想到使用一个 for
循环来改写函数 AppendAdditionalComponentMenuItems
中的代码:
protected override void AppendAdditionalComponentMenuItems(ToolStripDropDown menu)
{
for (var i = 1; i < 6; i++)
{
// 将对应的列表项的文字和赋值语句换成 i 即可
menu.Items.Add(new ToolStripMenuItem($"{i}", null,
(o, e) => { ComponentPropertyValue = i; this.ExpireSolution(true); }));
}
}
但是这个时候运行代码就会出现一个现象,无论选哪个,最后出来的结果都会是36。
?????
“这GH是出Bug了!”
其实不然,即便是一个控制台应用程序,下面这段代码也会只输出一个值:
static void Main()
{
var list = new List<Action>();
for (var x = 0; x < 10; x++)
{
list.Add(() => Console.WriteLine(x));
}
foreach (var action in list)
{
action();
}
}
甚至,在广为人知的另一门编程语言 Python
中,以及其他许多编程语言中,都会有这种情况。(在 Python
中,这种现象称之为“闭包延时绑定”,可自行搜索Python延时绑定关键词来查询相关底层知识)
我们先说怎么解决这个问题,再来谈这个问题是什么原因导致的。
如何解决
解决的方法很简单,只需要额外增加一个局部变量即可:
protected override void AppendAdditionalComponentMenuItems(ToolStripDropDown menu)
{
for (var i = 1; i < 6; i++)
{
var j = i; // 增加一个额外的变量j,令其值等于i,然后在lambda函数中使用j即可
menu.Items.Add(new ToolStripMenuItem($"{j}", null,
(o, e) => { ComponentPropertyValue = j; this.ExpireSolution(true); }));
}
}
简而言之,就是在 for
循环内部作用域,创建一个额外的临时变量(上例中的j
),令其等于循环控制变量(上例中的i
),然后在循环内部作用域使用这个额外的临时变量即可。
笔者提示:此外,如果循环控制变量(上例中的
i
)是引用类型(不是int/double/long等值类型),这个循环内部的额外临时变量则需要使用复制构造来创建新实例 —— 虽然很少出现使用非int
类型作为循环控制变量
这样一来,这个电池的工作就正常了:
为什么会是这样的
细心的读者已经发现了,在上面的例子中,我们都使用了 匿名函数。没错,问题就是出在 匿名函数 中。
匿名函数写起来十分方便,但其实在它简单的语法背后,编译器为我们做了许多额外的事情。其中之一就是对其中的变量做 “变量捕获 (Captures)”。
变量捕获描述的是这样一个过程:
对于匿名函数的函数体中使用到的不存在于函数输入参数的变量,匿名函数会捕获该变量的引用。在随后匿名函数被调用时,被捕获的变量的值将会是函数调用这一瞬间的值,而非匿名函数构造时的值。
上面两句话阐述了两个问题:
- 什么样的变量会被捕获
- 被捕获变量的行为是什么
下面看一个例子:
var x = 10;
Func<int, int> lambda = (int input) => input * x;
x += 10;
var result = lambda(5);
Console.WriteLine(result);
我们使用 Visual Studio 中的 C# Interactive 来执行上面的代码,可以看到,lambda(5)
的结果是100,而不是50。
- 匿名函数是:
(int input) => input * x
- 匿名函数的输入变量是
input
- 匿名函数体是
input * x
匿名函数体中包含了两个变量,input
和x
。因为input
是匿名函数的输入变量,所以它不是被捕获的变量。x
不是匿名函数的输入变量,所以它将会被匿名函数捕获。
在我们使用lambda(5)
调用匿名函数时,被捕获变量x
的值是匿名函数函数调用时的值(20,因为在调用前我们使用x += 10
改变了x
),而非匿名函数被定义的时候的值(10)。因此,最后的结果是 5 * 20 = 100
。
通过这个例子,我们可以看出:
匿名函数中的被捕获的变量的值会是匿名函数被调用时的值,而非匿名函数构造时的值。
因此,在的Grasshopper电池菜单项的问题上,我们构造菜单项时,是嵌套在 for
循环中,构造匿名函数时,由于循环变量i
并不是匿名函数的输入参数,所以它将会被捕获!我们通过 for
循环构造了5个菜单项,但他们的回调函数捕获的是同一个循环变量 i
。
protected override void AppendAdditionalComponentMenuItems(ToolStripDropDown menu)
{
for (var i = 1; i < 6; i++)
{
menu.Items.Add(new ToolStripMenuItem($"{i}", null,
(o, e) => { ComponentPropertyValue = i; this.ExpireSolution(true); }));
}
}
进一步的,在菜单被点击的时候,回调函数被触发,此时匿名函数内的i
的值会是匿名函数被调用时候的值(此时,构造菜单项的 for
循环早已完成,因此循环变量停留在了最后一次 for
循环的值6)。这也是为什么我们在之前出现,任何一个菜单项点击都是6的结果的原因。
老规矩,上代码
using System;
using System.Windows.Forms;
using Grasshopper.Kernel;
namespace GrasshopperPluginExample01
{
public class ProvideValues : GH_Component
{
public ProvideValues() : base("ProvideValues", "Val",
"ProvideValues",
"Params", "DigitalCrab")
{
}
private int ComponentPropertyValue;
protected override void RegisterInputParams(GH_Component.GH_InputParamManager pManager) { }
protected override void RegisterOutputParams(GH_Component.GH_OutputParamManager pManager)
{
pManager.AddIntegerParameter("Out", "O", "output value", GH_ParamAccess.item);
}
protected override void SolveInstance(IGH_DataAccess DA)
{
DA.SetData(0, ComponentPropertyValue * ComponentPropertyValue);
}
protected override void AppendAdditionalComponentMenuItems(ToolStripDropDown menu)
{
//menu.Items.Add(new ToolStripMenuItem("1", null, (o, e) => { ComponentPropertyValue = 1; this.ExpireSolution(true); }));
//menu.Items.Add(new ToolStripMenuItem("2", null, (o, e) => { ComponentPropertyValue = 2; this.ExpireSolution(true); }));
//menu.Items.Add(new ToolStripMenuItem("3", null, (o, e) => { ComponentPropertyValue = 3; this.ExpireSolution(true); }));
//menu.Items.Add(new ToolStripMenuItem("4", null, (o, e) => { ComponentPropertyValue = 4; this.ExpireSolution(true); }));
//menu.Items.Add(new ToolStripMenuItem("5", null, (o, e) => { ComponentPropertyValue = 5; this.ExpireSolution(true); }));
for (var i = 1; i < 6; ++i)
{
var j = i;
menu.Items.Add(new ToolStripMenuItem($"{j}", null, (o, e) => { ComponentPropertyValue = j; this.ExpireSolution(true); }));
}
}
protected override System.Drawing.Bitmap Icon => null;
public override Guid ComponentGuid => new("7805627F-6422-457D-969D-C5E19B124D87");
}
}
下次再见 🦀