我所理解的委托和匿名函数

我所理解的委托和匿名函数


一、引言

委托和匿名函数在我们的日常编程中用得非常多,甚至有些同学用了大半年的匿名函数,却对其本质不甚了解,就像我们天天都要呼吸空气,却对空气质量和成分却没清晰的认识。委托和匿名函数虽然使用起来非常方便,但由于使用者理解不够深刻,不假思索地使用带来更多的是内存泄漏和过多的垃圾产生。那么这里,我就用一种我所理解的方式来给大家挖挖委托匿名函数的原理,让大家看看为什么这家伙用起来这么容易让人上瘾。

本文讲解的主要内容如下:

1)什么是委托和匿名函数;
2)实现的底层原理;
3)容易引起的问题;
4)如何正确高效地使用匿名函数;
5)如何实现一个不产生内存垃圾的通用委托。


二、什么是委托?

委托是一种引用类型,可以指向任意函数(无论静态动态,公开与私有,虚方法还是非虚方法),从而实现委托调用。(这里讨论的委托不考虑多播事件)

C#中声明形式如下:

delegate int DelegateType( int a, int b );

C#的委托来源于C语言中的函数指针,我们来看看C#的母亲语言C++中,函数指针是什么样的:

// 普通静态函数指针
typedef int ( *FP_ADD )( int a, int b );
// 成员函数指针
typedef int( TestClass::*FP_ADD2 )( int a, int b );

C++中的函数指针形式复杂,各种类型之间不能相互转型,用起来特别麻烦,尤其引入调用协议、成员方法、虚方法,甚至虚继承,多重继承的时候更是个难题。由于语言的标准并没有明确如何来实现调用机制,因此各个编译器厂商在实现各类函数调用的实现上不尽相同,例如一个虚函数指针,其携带的信息不仅仅只有函数入口地址那么简单,可能还包含有转型到派生类的偏移地址和虚函数表的索引。

C++到C#实现了一次语言进化,语言的标准强制了委托的行为一致性,更是让多数初级用户也能运用自如。

一位聪明的程序员,他可能觉得C++中的函数指针使用起来实在太麻烦,他眼红着隔壁的C#程序员无脑的用着语言为他们提供的Delegate,他发誓要在号称无敌的C++语言中也实现这么一个Delegate,造福子孙后代,于是他废寝忘食,终于成功研发了FastDelegate,这东西牛逼得一塌糊涂,不但好用,而且效率出奇的高,甚至超过了一些准库中的实现。一些编译器厂商纷纷向这位程序员讨教实现高效的函数调用的奥秘。https://www.codeproject.com/Articles/7150/Member-Function-Pointers-and-the-Fastest-Possible

C#中如何使用委托:

class TestClass {
    // 这里声明一个委托类型
    public delegate int DelegateType( int a, int b );
    // 一个成员变量
    int m_plus = 1;
    // 一个静态函数
    public static int Static_Add( int a, int b ) {
        return a + b;
    }
    // 一个成员函数
    public int Member_Add( int a, int b ) {
        return a + b + m_plus;
    }
    public static int Add( int a, int b, DelegateType overrideFunc = null ) {
        if ( overrideFunc != null ) {
            return overrideFunc( a, b );
        }
        return a + b;
    }
    public static void Test1() {
        // 传入一个静态成员方法
        Add( 1, 2, TestClass.Static_Add );
    }
    public static void Test2() {
        var obj = new TestClass();
        // 传入一个动态实例方法
        Add( 1, 2, obj.Member_Add );
    }
}

因为刚才讲到,委托是一种类型,而函数并不是类型,所以通常情况下,如果没有为两种类型提供转型操作,编译器会报出类型不匹配的错误。所以,既然我们这种写法能够通过编译器,那么说明编译器为我们实现了转型操作,比如隐式用于转型的构造函数。(知识点:隐式显示转型构造函数)。

如果委托的函数非常简单,或者基本上没有重复利用的可能,那么可以直接把函数体实现写在委托的生成处,这样你就不用费劲心思去思考该怎样给函数取一个容易的名字了,名字压根就不重要了,函数体那几句代码更易于理解代码在干什么,这就是匿名函数,也就是叫做lambda表达式的东西。

//匿名函数原始写法
Add(
    1, 2,
    delegate ( int a, int b ) {
        return a + b;
    }
);

//进一步简化成
Add(
    1, 2,
    ( a, b ) => {
        return a + b;
    }
);

//某些情况下
//编译器能让你更加简化代码
//比如大家常用的匿名函数省略掉了:参数列表括号,函数体括号,返回语句,解放了大家勤劳的双手
list.Find( e => e == "abc" )

//有些情况下,匿名函数能引用外层的变量,如下:
string name = "abc";
list.Find( e => e == name );

匿名函数是如此的友好,让你随时随地想用就用!再也不用绞尽脑汁为方法取名了,写代码简直就是行云流水,一气呵成啊!那么这一切便利都是免费的吗?当然不是!下面就要为您揭开匿名函数神秘的面纱。


三、匿名函数实现原理

为了让大家更易于理解匿名函数的由来和原理,我打算从一个发展多年古老而强大的编程语言C++讲起。我们知道C++是从C语言发展而来,从最初的面向过程编程引入了面向对象的编程,后来又逐步支持了函数式编程,元编程等多种编程范式,近年来C也逐步吸收了其他编程语言的优秀语法设计,从C++11开始引入了更多的新功能,为程序的编写者带来了更多便利,其中,就有我们本文讲解的匿名函数。

首先来看一个例子,实现查找目标值在数组中的索引:

static int find_index( const std::vector<int>& array, int value ) {
    int index = -1;
    for ( size_t i = 0; i < array.size(); ++i ) {
        if ( array[i] == value ) {
            index = i;
            break;
        }
    }
    return index;
}

int main() {
    std::vector<int> array{ 3, 1, 2 };
    int index = find_index( array, 1 );
    assert( index == 1 );
    return 0;
}

编写一个具有上述功能的函数虽然很简单,但是真实项目中,具有类似上述功能的函数却随处可见,为不同类型编写同类功能的函数无疑会浪费大量编程人员的时间,后来C++的标准库提供了通用算法库,使用起来方便了许多:

static int equal_to_1( int value ) {
    return value == 1;
}

int main() {
    std::vector<int> array{ 3, 1, 2 };
    // 查找值为1的项
    auto it = std::find_if( array.begin(), array.end(), equal_to_1 );
    // 转换迭代器为索引
    index = std::distance( array.begin(), it );
    assert( index == 1 );
    return 0;
}

有了标准库,再也不用为各种数据容器类型编写各种查找函数,库函数为模板函数,任意类型都能应用该算法,极大减轻了编程人员工作量。可是,用着用着,编程人员还是觉得用起来麻烦,虽然核心算法的算子函数很简单,但是写多了还是很麻烦。后来有位聪明的程序员钻了空子...

由于C++允许程序员重载操作符,如运算符:+,-,*,/, 还有取地址:&,->,最重要的连函数调用操作符“()”都能重载!有了函数调用符号的重载,我们能重新这样改写上面的例子:

class equal_to {
    int v;
public:
    equal_to( int value ) : v( value ) {}
    bool operator( int other ) {
        return v == other;
    }
};

// 调用处改为如下,更具实用性
std::find_if( array.begin(), array.end(), equal_to( 1 ) );

再来一个新的例子:

class Printer {
public:
    int printer_id;
    Printer( int id ) : printer_id( id ) {}
    void operator()( int value ) {
        // 打印函数会带上其id, 连同一起输出
        printf( "[%d] -> %d", printer_id, value );
    }
};

int main() {
    // 构造一个打印机对象,同时指定id为1000
    Printer printer( 1000 );
    // 调用打印 
    printer( 2 );
    return 0;
}

看见没有,居然可以把一个对象当成一个函数一样调用!

鸭子类型

在程序设计中,鸭子类型(duck typing)是动态类型的一种风格,在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由“当前方法和属性的集合”决定。

怎么样来理解这句话呢?鸭子类型这种称谓怎么来的?说起鸭子,我们脑中就会浮现起鸭子摇摇摆摆地走路,扑打这翅膀呱呱叫这么一种形象,那么我们反过来讲,如果我叫大家来猜一种摇摇摆摆走路呱呱叫的动物,大家也会很容易猜出来是鸭子。鸭子这种动物和它的形象已经被人类联系起来,它的行为反过来定义了它的种类,如果一个动物呱呱叫,走路摇摇摆摆,那么它就是鸭子,这就是鸭子定义的由来。

在动态语言中,数据的类型是不确定的,在对这个数据进行操作之前,你是无法看出来它到底是什么类型,一旦你把这个数据拿去参与实际操作,你就能看清楚这个数据的真实类型,这就是行为定义了它的类型。

回到打印机这个例子,我们从代码上看,Printer这个类型是一个类,但是由于我们为其编写了函数调用操作符,我们就可以像函数一样调用它,按照鸭子类型的说法,Printer这个类型就是个函数,也有人称其为仿函数(functor),表明其伪装身份。

理解了鸭子类型后,我们再回到刚才的例子,仿函数作为一个类,它能把一些数据作为成员变量存储在内部,供主体函数使用,比如例子中在构造equal_to对象时,比较值作为一个参数存到了仿函数对象体内,这样这个比较器便能使用这个数据了。

让我们回到最初的那个C#版本的例子,我们用反编译工具把代码反编译出来,看看编译器最终把我们的代码改写成了什么样子:

// 用ILSpy反编译出来的代码:
internal class TestClass {

    // 编译器发现这个匿名函数并没有引用外部变量,那么它就可以静态化
    // 声明一个静态的委托类型的变量,一次使用的时候初始化
    [CompilerGenerated]
    private static TestClass.DelegateType <>f__mg$cache0;

    public static void Test1() {
        int arg_20_0 = 1;
        int arg_20_1 = 2;
        if ( TestClass.<>f__mg$cache0 == null ) {
            // 第一次使用,用函数体构造委托对象,后续使用则不会触发内存分配
            TestClass.<>f__mg$cache0 = new TestClass.DelegateType(TestClass.Static_Add);
        }
        // 看见没,编译器最终传入的是实际生成的委托对象,
        // 而不是直接的函数,或者函数指针等等
        TestClass.Add( arg_20_0, arg_20_1, TestClass.<>f__mg$cache0 );
    }

    public static void Test2() {
        TestClass @object = new TestClass();
        // 委托引用了一个实例对象的成员函数,不能被静态化
        // 所以每次调用时,如果不做优化,每次都将产生临时的委托对象
        // 如果在程序关键代码中这样写,会导致严重的性能问题
        TestClass.Add( 1, 2, new TestClass.DelegateType( @object.Member_Add ) );
    }

    // 编译为lambda表达式生成了一个对象,用于存储引用的外部变量
    // 此方法和我们上文讲的在C++ 中实现一个仿函数的方法完全一样
    [CompilerGenerated]
    private sealed class <Test3>c__AnonStorey0 {
        // Fields
        internal TestClass $this;
        internal int upvalue;
        // Methods
        public <Test3>c__AnonStorey0();
        internal int <>m__0( int a, int b );
    }

    public void Test3() {
        <Test3>c__AnonStorey0 storey;
        // 构造一个临时对象,用于产生委托和存储数据
        storey = new <Test3>c__AnonStorey0();
        storey.$this = this;
        storey.upvalue = 3;
        // 该委托引用了非静态数据,所以不能被静态化,需要实时构造
        // 这里产生了更多的内存分配操作,使用不当,会引起严重性能问题
        Add( 1, 2, new DelegateType( storey.<>m__0 ) );
        return;
    }
}

可以看出来编译器为我们做了类型转换工作,并且为我们生成了正确的仿函数代码,这样我们就可以写出更简洁的代码,极大地提高了编码效率。但是正是由于简洁的代码隐藏了实现细节,如果我们不理解其本质原理,那么我们可能会写出导致最终程序运行效率低下,甚至错误的代码。


四、如何正确地使用匿名函数

上面花了较多的篇幅来讲解委托和匿名函数的前世今生,现在为了正确使用匿名函数,我们来分析一个例子:

// 函数功能:打印从0~9这几个数字
// 错误:程序总是输出10,而不是0~9的序列
void Error1() {
    var printList = new List<Action>();
    for ( int i = 0; i < 10; ++i ) {
        // 循环里面一直在构造匿名函数对象,分配大量的内存
        printList.Add( () => Debug.Log( i ) );
    }
    for ( int j = 0; j < printList.Count; ++j ) {
        printList[ j ](); // 结果总是输出10
    }
}

void Error2() {
    var list = new List<int>();
    list.Add( 1 );
    list.Add( 2 );

    int id = 0;

    if ( id == 5 ) {  // 假如满足几率很小

        // 表面上看,匿名函数对象在此处构造
        // 但实际上,匿名对象在id声明处就已经提前构造好了
        // 这样会 100% 造成内存分配
        list.Find( value => value == id );
    }
}

反编译后关键代码:

void Error1() {
    ...
    IL_0001: newobj instance void class [mscorlib]System.Collections.Generic.List`1<class [System.Core]System.Action>::.ctor()
    ...
    IL_002f: ldfld int32 TestClass/'<Error1>c__AnonStorey1'::i
    ...
    // 说明了匿名函数对象引用的循环变量已经被包裹进了匿名对象内
    // 循环变量的值存在堆中,而非栈上了
    // 这样导致后面执行函数代码取出来的值都是同一个值
}

void Error2() {
    // 函数体第一个指令就创建了匿名对象
    IL_0000: newobj instance void TestClass/'<Error2>c__AnonStorey2'::.ctor()
    ... 
    // id的引用都是匿名对象的成员变量
    IL_001d: stfld int32 TestClass/'<Error2>c__AnonStorey2'::id
}

所以要正确使用匿名函数,必须清楚以下几点:

1. 尽可能避免匿名函数引用外部变量,让其可被静态化
2. 搞清楚哪些变量被匿名函数引用了,防止内存泄漏
3. 尽量把被引用的变量声明放在后面,用变量复制来延迟匿名函数创建

用上面的规则来修正上面代码的错误:

void Error1() {
    var printList = new List<Action>();
    for ( int i = 0; i < 10; ++i ) {
        // 为了避免循环变量被引用
        // 复制i到局部变量,让其被匿名函数引用
        var _i = i;
        printList.Add( () => Debug.Log( _i ) );
    }
    // 结果虽然正确了,但实际编码中,还是要避免循环中构造匿名函数
    // ...
}

void Error2() {
    var list = new List<int>();
    // ...
    int id = 0;
    if ( id == 5 ) {
        // 同理,这样匿名函数构造位置延迟到了条件表达式体内
        // 消除多数时候的内存分配操作
        var _id = id;
        list.Find( value => value == _id );
    }
}

C#库函数的优化

虽然C#的框架为我们提供了大量好用的类库,但是也有很多优化空间。拿我们常用的List 这个类来说,以下函数就容易引起匿名函数的问题:

  • List.Find( Predicate match );
  • List.FindAll( Predicate match );
  • List.FindIndex( Predicate match );
  • List.FindLastIndex( Predicate match );
  • List.ForEach( Action action );
  • List.RemoveAll( Predicate match );
  • List.TrueForAll( Predicate match );

可以看到,以上函数的特点都是:带有委托类型的参数,有委托就可能引起匿名函数的问题。我们可以通过编写扩展函数来消除匿名函数问题,同时保持了易用性。

public static class ListEx {

    // 把匿名函数引用的外部变量(上下文)通过参数传入,使其可以被静态化,避免每次调用产生堆内存分配
    public static int FindIndex<T, C>( this IList<T> list, C ctx, Func<T, C, bool> match ) {
        UDebug.Assert( ( Delegate )match.Target == null );
        for ( int i = 0, count = list.Count; i < count; ++i ) {
            if ( match( list[ i ], ctx ) ) {
                return i;
            }
        }
        return -1;
    }
}

// 使用方法:
// 系统函数的调用方式
int id = 5
list.FindIndex( e => e == id );

// 替换成如下写法,其中id作为上下文被当做参数传入了,匿名函数对象可以背静态化了,消除了每次的堆内存分配
list.FindIndex( id, ( _id, e ) => e == _id );

可以看到,自己扩展的库函数比标准库只多了一个参数ctx,那如果需要传入的外部变量超过一个怎么办呢?这时候就需要引入一个新的伙伴:元组

元组泛指有限个元素所组成的序列,在程序设计语言中,元组被用来构建数据结构。

更通俗地讲,元组就是将一堆数据打包在一起的一个通用结构,比如KeyValuePair就是一个最简单的元组,能容纳Key和Value这两个值。

如果要存储更多数据,就需要标准库提供的Tuple了,但是最大的问题是该实现Tuple是一个引用类型,不能在栈上分配,如果要使用就不能避免堆内存分配,所以我们需要仿造其原理,自己实现一个值类型的Tuple。

STuple实现:(S代表Struct,值类型实现,同时与标准库区分开)
请输入图片描述

由于也采用了泛型编程,该STuple使用起来也很方便:

// 同时打包了四种类型的值类型:int, float, double, bool
var pack = STuple.Create( 1, 1.0f, 1.0, true )
var text = String.Format( "{0}, {1}, {2}, {3}", pack.Item1, pack.Item2, pack.Item3, pack.Item4 );

结合前文提提到的匿名函数优化扩展:

// 查找容器中同时满足id和name相等的元素索引
int id = 5
int name = "abc"
list.FindIndex( STuple.Create( id, name ), ( ctx, e ) => e.id == ctx.Item1 && e.name == ctx.name );

五、如何实现一个不产生内存垃圾的通用委托

实现一个不产生垃圾的通用委托是我们的最终目的,这样我们就能尽可能地替换现有代码的实现,最大程度地优化效果。

由于系统提供的两大通用委托Action,Func被使用得最多,所以我们在这里实现的通用委托也采用相同的形式来实现。

在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。

通俗来讲就是包裹了外部自由变量的函数可以看做是一个闭包,而在多数实际编码中,会将匿名函数和外部变量一起使用构成一个闭包来使用。要实现一个自己的通用闭包对象,需要实现以下几个功能:

  1. 通用的函数存储(Action, Func存储)
  2. 通用的数据存储(有限个)
  3. 易用的构造形式

通用的函数/委托和数据的存储

存储形如Action < T >,Func< T, TRet >以及众多的泛型形式,我们可以直接把委托转型为基础类型Delegate来存储,调用的时候在转型回来,没有什么难度。

数据存储是关键,比较麻烦。由于我们要避免堆内存分配,所以boxing/unboxing也要避免,最好不使用object来做通用数据存储。实际编码中,大多数常用的值类型就那几种,所以适当折中,实现比如int, float, double, bool,string, object等基础类型即可。

回忆C/C++中,union类型因其字段共享内存,通常被用于存储多种类型的数据并且能够保证内存最高的使用率。

// 这样一个联合体类型能够存储5种类型的数据,并且最大程度保证了内存利用率
// 这个结构占用的字节数仅仅12个字节(32位环境)
struct variant_t {
    int type;
    union {
        int i;
        float f;
        double d;
        bool b;
        void* p;
    };
};

在C#中也有能实现union联合体的内存排布的方法,所以我们一样能实现一个值类型的任意数据存储类型:

// 值类型的通用数据存储结构
public struct SValue {

        public enum Type {
            Nil,
            Boolean,
            Int8,
            UInt8,
            Char,
            Int16,
            UInt16,
            Int32,
            UInt32,
            Int64,
            UInt64,
            Single,
            Double,
            String,
            Object,
            //...
        }

        // 显式指定每个成员内存排布,通过把每个成员的
        // 内存地址偏移都设置为0,实现union的效果
        [StructLayout( LayoutKind.Explicit )]
        internal struct __Value {
            [FieldOffset( 0 )]
            internal Boolean _bool;

            [FieldOffset( 0 )]
            internal SByte _int8;

            [FieldOffset( 0 )]
            internal Byte _uint8;

            [FieldOffset( 0 )]
            internal Char _char;

            [FieldOffset( 0 )]
            internal Int16 _int16;

            [FieldOffset( 0 )]
            internal UInt16 _uint16;

            [FieldOffset( 0 )]
            internal Int32 _int32;

            [FieldOffset( 0 )]
            internal UInt32 _uint32;

            [FieldOffset( 0 )]
            internal Int64 _int64;

            [FieldOffset( 0 )]
            internal UInt64 _uint64;

            [FieldOffset( 0 )]
            internal Single _single;

            [FieldOffset( 0 )]
            internal Double _double;

            //... 更多的常用值类型数据存储
        };
        Type type; // 用于表示当前存储的数据类型,获取值需要检查类型
        __Value _val;
        System.Object obj; // 用于存储所有的引用类型,如String

        public static SValue Ctor( Int32 val ) {
            return new SValue {
                type = Type.Int32,
                _val = new __Value { _int32 = val }
            };
        }

        public static SValue Ctor( Single val ) {
            return new SValue {
                type = Type.Single,
                _val = new __Value { _single = val }
            };
        }

        public static SValue Ctor( String val ) {
            return new SValue {
                type = Type.String,
                obj = val
            };
        }
        // 更多重载函数版本来从不同类型的值来构造自己
        // ...

        public Int32 ToInt32()
        public UInt32 ToUInt32()
        public Single ToSingle()
        // ...

        // 避免object类型影响函数重载决议,需要换个名字
        public static SValue FromObject( System.Object val ) {
            return new SValue {
                type = Type.Object,
                obj = val
            };
        }

        public System.Object ToObject() {
            if ( type == Type.Object ) {
                return obj;
            }
            return null;
        }
    }
}

上面这个SValue使用起来比较麻烦,所以我们需要为它加入更多的泛型支持:

public struct SValue {

    // C#没有类似C++的模板特化功能,所以需要自己来实现

    public class Reader<T> {
        internal static Func_ByRef<SValue, T> _invoke = null;
        internal static Func_ByRef<SValue, T> _default = ( ref SValue val ) => ( T )val.ToObject();
        public static Func_ByRef<SValue, T> invoke {
            get {
                return _invoke ?? _default;
            }
        }
        static Reader() {
            ReaderInit.DoInit();
        }
    }

    public class Writer<T> {
        internal delegate void TW( T v );
        internal static Func<T, SValue> _invoke = null;
        internal static Func<T, SValue> _default = v => {
            // 加入类型检测,避免产生装箱
            UDebug.Assert( typeof( T ).IsValueType == false, "Please avoid value type boxing!" );
            return SValue.FromObject( v );
        };
        public static Func<T, SValue> invoke {
            get {
                return _invoke ?? _default;
            }
        }
        static Writer() {
            WriterInit.DoInit();
        }
    }

    internal static class ReaderInit {
        static ReaderInit() {
            Reader<Boolean>._invoke = ( ref SValue s ) => s.ToBoolean();
            Reader<Char>._invoke = ( ref SValue s ) => s.ToChar();
            Reader<Byte>._invoke = ( ref SValue s ) => s.ToByte();
            Reader<SByte>._invoke = ( ref SValue s ) => s.ToSByte();
            // 更多特化版本
        }
        public static void DoInit() {
            // 仅仅为了调用静态构造函数一次
        }
    }

    internal static class WriterInit {
        static WriterInit() {
            Writer<Boolean>._invoke = v => SValue.Ctor( v );
            Writer<Char>._invoke = v => SValue.Ctor( v );
            Writer<Byte>._invoke = v => SValue.Ctor( v );
            // 更多特化版本
        }
        public static void DoInit() {
        }
    }
}

准备了大半天,终于有了用来存储任意数据的对象了,回到闭包对象的设计上来,我们可以定义两个版本的闭包
ActionClosure, FuncClosure分别对应Action,Func,具体如下:

// 最多能存4个外部变量
public struct Closure {
    public SValue _0;
    public SValue _1;
    public SValue _2;
    public SValue _3;
    public Delegate _delegate;
}

public struct ActionClosure {
    Closure _closure;
    Action<Closure> _wrapper; // 用于适配Action类型的委托调用
    // 提供给用户调用
    public void Invoke() {
        if ( _wrapper != null ) {
            _wrapper( _closure );
        }
    }
}

public struct FuncClosure {
    Closure _closure;
    Func<Closure, SValue> _wrapper; // 用于适配Func类型的委托调用
    // 提供给用户调用,带有返回值
    public T Invoke<T>() {
        if ( _wrapper != null ) {
            var s = _wrapper( _closure );
            // 把返回值从SValue中提取出来,传出去
            return SValue.Reader<T>.invoke( ref s );
        }
        return default( T );
    }
}

为了实现对应形如Action, Action

public struct ActionClosure {
        public static ActionClosure Create<T0, T1>( Action<T0, T1> action, T0 ctx0, T1 ctx1 ) {
            // 可以通过此判断式,来确认外部传入的匿名函数是否是静态函数
            // 如果不是,则说明该匿名函数的创建产生了内存开销
            UDebug.Assert( ( ( Delegate )action ).Target == null );
            // 使用栈上内存
            return new ActionClosure {
                // Closure是值类型,这里使用栈上内存
                _closure = new Closure {
                    _0 = SValue.Writer<T0>.invoke( ctx0 ),
                    _1 = SValue.Writer<T1>.invoke( ctx1 ),
                    _delegate = action
                },
                // 构造一个适配器
                _wrapper = e.Invoke<T0, T1>()
            };
        }
    }
    
    // 实际使用:
    void test() {
        int a = 1;
        int b = 2;
        var action = ActionClosure.Create( ( _a, _b ) => UDebug.Print( _a + _b ), a, b );
        action.Invoke();
    }

实际运行中,我们还是发现每次构造ActionClosure对象时,还是产生了堆内存分配,这是为什么呢?不是说,匿名函数如果不引用外部变量,就能静态化,就不会产生内存开销了吗?

这就要回到我们之前反汇编出来TestClass这个类的代码来看看编译器是如何生成代码的,编译器在遇到匿名函数时,首先看其是否能静态化,然后尝试在其类中声明一个用于转型的静态委托成员,第一次调用时产生构造操作,实现后续的调用无多余内存开销。

但是这里为什么编译器没有成功生成静态委托成员呢?我们不妨先仿造编译器代码生成的规律来产生这个静态委托:

public struct ActionClosure {
    delegate void _Generated_Action( T0 a, T1 b );
    static _Generated_Action _Action = null;

    public static ActionClosure Create<T0, T1>( Action<T0, T1> action, T0 ctx0, T1 ctx1 ) {
        UDebug.Assert( ( ( Delegate )action ).Target == null );
        if ( _Action == null ) {
            _Action = new Action<Closure>( action );
        }
        return new ActionClosure {
            _closure = new Closure {
                _0 = SValue.Writer<T0>.invoke( ctx0 ),
                _1 = SValue.Writer<T1>.invoke( ctx1 ),
                _delegate = action
            },
            _wrapper = _Action
        };
    }
}

很显然,以上代码有两处明显的错误导致不能被正确编译:

  1. 无法在一个非泛型的类中声明一个泛型的成员;
  2. 无法将一个泛型委托传给一个非泛型的委托。
public struct ActionClosure {
    
        // 泛型参数T0, T1未定义
        delegate void _Generated_Action( T0 a, T1 b );
    
    
        public static ActionClosure Create<T0, T1>( Action<T0, T1> action, T0 ctx0, T1 ctx1 ) {
            if ( _Action == null ) {
                // 参数类型不匹配
                _Action = new Action<Closure>( action );
            }
            // ...
        }
    }

显然,编译器不可能生成一个无法通过编译的代码,所以简单起见,编译器放弃治疗,采用最直白的方式生成适配代码,虽然不能避免堆内存分配,但能正常工作:

_wrapper = new Action<Closure>( e => e.Invoke<T0, T1>() )

为了优化彻底,我们需要完成编译器所不能完成的任务,生成适配的委托构造:

// 定义一个泛型的类,用来来存储静态泛型委托
internal class ActionClosureWrapper<T0, T1> {
    // 手动构造适配函数
    internal static Action<Closure> _default = e => e.Invoke<T0, T1>();
}

public struct ActionClosure {
    public static ActionClosure Create<T0, T1>( Action<T0, T1> action, T0 ctx0, T1 ctx1 ) {
        UDebug.Assert( ( ( Delegate )action ).Target == null );
        return new ActionClosure {
            _closure = new Closure {
                _0 = SValue.Writer<T0>.invoke( ctx0 ),
                _1 = SValue.Writer<T1>.invoke( ctx1 ),
                _delegate = action
            },
            // 直接使用我们生成好的委托用来适配接口
            _wrapper = ActionClosureWrapper<T0, T1>._default
        };
    }
}

这样,一个完全不产生垃圾的通用委托就完成了,接下来各位朋友可以按需替换成现有代码了,enjoy!


六、结语

虽然系统为我们提供了大量好用的工具类库,但往往有些时候这些库为了适应各种应用场景做了各种妥协而导致性能降低,毕竟性能和正确性两者,优先选择正确性。所以这时,只有自己动手发挥创造力,打造最适合当前场景的最优解决方案。

源代码已经上传,要的请自取,别忘了送个star哦:)
https://github.com/lujian101/GCFreeClosure


这是侑虎科技第310篇原创文章,感谢作者卢建供稿。欢迎转发分享,未经作者授权请勿转载。作者QQ:345005607。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

同时,作者也是U Sparkle活动参与者哦,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!

  • lujian 发表在 2017年10月20日 回复

    嗯,通用委托就可以理解为手动实现,不能说编译器的实现不好,而是编译器实现过于通用,导致像游戏中对内存要求好的地方有点不合时宜。我这个版本效率肯定是不及编译器的,只是说在有些调用不那么频繁的地方非热点代码可以用一用。(这个委托实现没用什么黑科技,开销都在那里:参数的保存,读取,和文章中忘了提到的委托转型,都是开销)

    参看:
    public struct Closure {
    public void Invoke() {
    ( _delegate as Action )(
    SValue.Reader.invoke( ref _0 )
    );
    }
    }

    至于编译器优化这个问题,我个人认为它不会那么激进和智能,保守不出错才是更好的选择,可以看看那些C++编译器优化高了有些什么副作用就够你喝一壶了。

  • Alpha_uwa 发表在 2017年10月19日 回复

    你好。先感谢一下精彩的文章。蛮有收获的。

    最后那个通用委托能否这么理解:实际上是把C#委托机制手动实现了一下,能自己掌控哪里有内存开销,而不是靠编译器底层去做。感觉这一套东西更多的是意识上,使用的时候我们能比较明确感知到内存开销,进而写出更好的代码,不像使用 C# 的委托,不懂的话就完全不知道存在内存开销。
    不过一套委托代码,函数参数在创建委托的时候就定死了(虽然可以手动去重新赋值委托里的保存函数参数变量, 而这也会带来新参数Packer的创建开销),感觉这是使用上一个局限。而且是不是编译器如果写的得好一点(或者编译开关打高一点?),几乎可以把写的好一点的C#委托优化到这样子。