近期经常收到一个问题 —— “为什么我添加的右键菜单项有Bug?” “我用了一个for循环去添加菜单项,想一次性添加x个菜单项,并在菜单被点击的时候执行 xxxx,但是结果总是不变,而且不对,这是不是GH出Bug了?”

相信有不少二开的小伙伴会做这样的一个需求:需要一个电池,这个电池需要依照情况输出若干个确定的值,具体输出哪个值需要用右键菜单来指定。类似于 ValueList 电池那样可以通过选择来输出若干个指定值其中的一个。

【Grasshopper基础15】“右键菜单似乎不太对劲”-LMLPHP

【Grasshopper基础15】“右键菜单似乎不太对劲”-LMLPHP

要实现这个功能,最简单直观的就是在电池中加入一个属性叫 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。

【Grasshopper基础15】“右键菜单似乎不太对劲”-LMLPHP

?????

“这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();
    }
}

【Grasshopper基础15】“右键菜单似乎不太对劲”-LMLPHP

甚至,在广为人知的另一门编程语言 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),然后在循环内部作用域使用这个额外的临时变量即可。

这样一来,这个电池的工作就正常了:

【Grasshopper基础15】“右键菜单似乎不太对劲”-LMLPHP

为什么会是这样的

细心的读者已经发现了,在上面的例子中,我们都使用了 匿名函数。没错,问题就是出在 匿名函数 中。

匿名函数写起来十分方便,但其实在它简单的语法背后,编译器为我们做了许多额外的事情。其中之一就是对其中的变量做 “变量捕获 (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。

【Grasshopper基础15】“右键菜单似乎不太对劲”-LMLPHP

  • 匿名函数是: (int input) => input * x
  • 匿名函数的输入变量是 input
  • 匿名函数体是 input * x

匿名函数体中包含了两个变量,inputx。因为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");
    }
}

下次再见 🦀

08-30 10:04