.NET面试题系列[12] - C# 3.0 LINQ的准备工作

"为了使LINQ能够正常工作,代码必须简化到它要求的程度。" - Jon Skeet

为了提高园子中诸位兄弟的英语水平,我将重要的术语后面配备了对应的英文。

.NET面试题系列目录

隐式类型的局部变量

隐式类型允许你用var修饰类型。用var修饰只是编译器方便我们进行编码,类型本身仍然是强类型的,所以当编译器无法推断出类型时(例如你初始化一个变量却没有为其赋值,或赋予null,此时就无法推断它的类型),用var修饰就会发生错误。另外,只能对局部变量使用隐式类型。

使用隐式类型的几个时机:

  1. 当变量的类型太长或者难以推测,但类型本身不重要时,比如你的LINQ语句中用了Groupby,那么一般来说基本很少人可以准确地推测出结果的类型吧。。。
  2. 当变量初始化时,此时可以根据new后面的类型得知变量类型,故不会对可读性造成影响
  3. 在Foreach循环中你迭代的对象,此时一般不需要显式指出类型

总的来说,如果使用隐式类型导致你的代码的可读性下降了,那么就改用显式类型。一般第二条原则已经是一个不成文的规定了。Resharper在检测到变量初始化时,如果你没有使用隐式类型,也会提醒你可以用var代替之。

LINQ中隐式类型的体现:你可以统统用var来修饰LINQ语句返回的类型。一般来说LINQ语句的返回类型通常名字都比较长,而且也不是十分显而易见。如果没有隐式类型,在写代码时就会比较痛苦。

自动实现的属性

现在应该满世界都在用自动实现的属性了。注意在结构体中使用自动实现的属性(注意字段不需要),需要显式的调用无参构造函数this()。这是结构体和类的一个区别。

public struct Foo
    {
        public int a { get; private set; }

        Foo(int A) : this()
        {
            a = A;
        }
    }

上面代码如果去掉this()将会发生错误,在默认无参构造函数将结构体的属性设为默认值之前,不能使用这些属性。如果将上面代码的属性改为字段,则即使不调用this()也不会有问题。

 

匿名类型(Anonymous Type) 

匿名类型允许你直接在括号中建立一个类型。虽然不需要指定成员的具体类型,但匿名类型的成员都是强类型的。

        static void Main(string[] args)
        {
            var tom = new {Name = "Tom", Age = 15};
            Console.WriteLine("{0}: {1}", tom.Name, tom.Age);
        }

对匿名类型进行初始化之后,就可以如同实际类型一样使用点符号获取匿名类型的成员,但变量tom只能用var或者object修饰。如果两个匿名类型有相同数量的成员,且所有成员拥有相同的类型名称和值的类型,而且以相同的顺序出现,则编译器会将它们看作是同一个类型。

        static void Main(string[] args)
        {
            var family = new[]
            {
                new {Name = "Tom", Age = 15},
                new {Name = "Jerry", Age = 16}
            };
            var cat = new {Age = 27, Name = "Cat"};
            var dog = new {Age = 2222222222222222, Name = "Dog"};

        }

如果在初始化中交换了属性的顺序,或者某个属性使用了long而不是int,则会引入一个新的匿名类型。

匿名类型包含了一个默认的构造函数,它获取你赋予的所有初始值。另外,它包含了你定义的类型成员,以及继承自object类型的若干方法(重写的Equals, 重写的GetHashCode, ToString等等)。同一个匿名类型的两个实例在判断相等性时,采用的是依次比较每个成员的值的方式。

在LINQ中,我们可以使用匿名类型来装载查询返回的数据,尤其是最后使用Select或SelectMany等方法返回若干列时。在每次查询都要为返回数据定制一个类显得太繁琐了,虽然有时候是需要的(ViewModel),但也有时候只是为了一次性的展示数据。如果你要创建的类型只在一个方法中使用,而且其中只有简单的字段或者属性而没有方法,则可以考虑使用匿名类型。

表达式和表达式树(Expression & Expression Tree)

Express是表达的意思(它还有很多其他意思,例如快速的),加上名词后缀-sion即为表达式。

表达式是当今编程语言中最重要的组成成分。简单的说,表达式就是变量、数值、运算符、函数组合起来,表示一定意义的式子。例如下面这些都是(C#的)表达式:

  • 3                             //常数表达式
  • a                             //变量或参数表达式
  • !a                            //一元逻辑非表达式
  • a + b                       //二元加法表达式
  • Math.Sin(a)              //方法调用(lambda)表达式
  • new StringBuilder()   //new 表达式

表达式的一个重要的特点是它可以无限组合,只要符合正确的类型和语义。表达式树则是将表达式转换为树形结构,其中每个节点都是表达式。表达式树通常被用于转换为其他形式的代码。例如LINQ to SQL将表达式树转译为SQL。

最基本的几种表达式

  • 常量表达式:Expression.Constant(常量的值);
  • 变量表达式:Expression.Parameter(typeof(变量类型), "变量名称")
  • 二元表达式,即需要两个表达式作为参数进行操作的表达式:Expression.[某个二元表达式的方法,例如加减乘除,模运算等](表达式1, 表达式2);
  • Lambda表达式:表达一个方法,可以接受一个代码段或一个方法调用表达式作为方法,以及一组方法参数。Lambda为一希腊字母,无法翻译。希腊字母还有很多,例如阿尔法,贝塔等。之所以选择这个字母是因为来自数学上的原因(数学上有lambda运算)

构建一个最简单的表达式树1+2+3

表达式树是对象构成的树,其中每个节点都是表达式。可以说,每个表达式都是一个表达式树,特别的,某些表达式可以看成只有一个节点的表达式树,例如常量表达式。System.Linq.Expressions命名空间下的Expression类和它的诸多子类就是这一数据结构的实现。Expression类是一个抽象类,主要包含一些静态工厂方法。Expression类也包含两个属性:

  • Type:代表表达式求值之后的.net类型,例如Expression.Constant(1)和Expression.Add(Expression.Constant(1), Expression.Constant(2))的类型都是Int32。
  • NodeType:代表表达式的种类。例如Expression.Constant(1)的种类是Constant,Expression.Add(Expression.Constant(1), Expression.Constant(2))的种类是Add。

每个表达式都可以表示成Expression某个子类的实例。例如BinaryExpression就表示各种二元运算符(例如加减乘除)的表达式。它需要两个运算数(注意运算数也是表达式):

    public static BinaryExpression Add(Expression left, Expression right);

Expression各个子类的构造函数都是不公开的,要创建表达式树只能使用Expression类提供的静态方法。

要创建一个表达式树,首先我们要画出这个树,并找出它需要什么类型的表达式。例如如果我们要创建1 + 2 + 3这个表达式的表达式树,因为它太简单而且不包含多于一种运算(如果有加有乘还要考虑优先级),我们可以一眼看出,其只需要两种表达式,常量表达式(形容1,2,3)和二元表达式(形容加法),所以可以这样写:

ConstantExpression exp1 = Expression.Constant(1);
ConstantExpression exp2 = Expression.Constant(2);
BinaryExpression exp12 = Expression.Add(exp1, exp2);
ConstantExpression exp3 = Expression.Constant(3);
BinaryExpression exp123 = Expression.Add(exp12, exp3);

这个应该非常好理解。但如果我们想写出Math.Sin(a)这个表达式的表达式树怎么办呢?为了解决这个问题,Lambda表达式登场了,它可以表示一个方法。

 

使用Lambda表达式表示一个函数

我们的目标是使用Lambda表达式表示Math.Sin(a)这个表达式。Lambda表达式代表一个函数,现在它具有一个输入a(我们使用变量表达式ParameterExpression来代表,它应该是double类型),以及一个方法调用,这需要MethodCallExpression类型的表达式,方法名为Sin,位于Math类中。我们需要使用反射找出这个方法。

代码如下:

ParameterExpression expA = Expression.Parameter(typeof(double), "a"); //参数a
MethodCallExpression expCall = Expression.Call(typeof(Math).GetMethod("Sin", BindingFlags.Static |   BindingFlags.Public), expA); //Math.Sin(a)
LambdaExpression exp = Expression.Lambda(expCall, expA); // a => Math.Sin(a)

 

使用Lambda表达式:通过Expression<TDelegate>

Expression<TDelegate>泛型类继承了LambdaExpression类型,它的构造函数接受一个Lambda表达式。此处TDelegate指泛型委托,它可以是Func或者Action。泛型类以静态的方式确定了返回类型和参数的类型。

对于上个例子,我们的输入和输出均为一个Double类型,故我们需要的委托类型是Func<double, double>:

Expression<Func<double, double>> exp2 = d => Math.Sin(d);

可以使用Compile方法将Expression<TDelegate>编译成TDelegate类型(在这个例子中,编译之后的对象类型为Func<double,double>),这是一个将表达式树编译为委托的简便方法(不需要再一步一步来,并且使用反射了)。编译器自动实现转换。

然后就可以直接调用,获得表达式计算的结果:

Expression<Func<double, double>> exp2 = d => Math.Sin(d);
Func<double, double> func = exp2.Compile();
Console.WriteLine(func(0.5));

练习:使用两种方法构建表达式树(a, b, m, n) => m * a * a +  n * b * b

假定所有的变量类型都是double。

代码法:

//(a, b, m, n) => m * a * a +  n * b * b
ParameterExpression expA = Expression.Parameter(typeof(double), "a"); //参数a
ParameterExpression expB = Expression.Parameter(typeof(double), "b"); //参数b
ParameterExpression expM = Expression.Parameter(typeof(double), "m"); //参数m
ParameterExpression expN = Expression.Parameter(typeof(double), "n"); //参数n

BinaryExpression multiply1 = Expression.Multiply(expM, expA);
BinaryExpression multiply2 = Expression.Multiply(multiply1, expA);
BinaryExpression multiply3 = Expression.Multiply(expN, expB);
BinaryExpression multiply4 = Expression.Multiply(multiply3, expB);
BinaryExpression add = Expression.Add(multiply2, multiply4);

委托法:

Expression<Func<double, double, double, double, double>> exp4 = (a, b, m, n) => m*a*a + n*b*b;

var ret = exp4.Compile();
Console.WriteLine(ret.Invoke(1, 2, 3, 4)); // =3*1*1+4*2*2=3+16=19

通过Expression<TDelegate>以及Compile方法,我们可以方便的计算表达式的结果。但如果一步步来,我们还需要手动遍历这棵树。既然使用代码构造表达式如此麻烦,为什么还要这样做呢?只是因为在手动遍历和计算表达式结果时,可以插入其他操作。LINQ to SQL就是通过递归遍历表达式树,将LINQ语句转换为SQL查询的,这是委托所不能替代的。

不是所有的Lambda表达式都能转化成表达式树。不能将带有一个代码块的Lambda转化成表达式树。表达式中还不能有赋值操作,因为在表达式树中表示不了这种操作。

参考资料:表达式树上手指南 http://www.cnblogs.com/Ninputer/archive/2009/08/28/expression_tree1.html

扩展方法(Extension Method)

扩展方法可以理解成,为现有的类型(现有类型可以为自定义的类型和.Net 类库中的类型)扩展(添加)一些功能,附加到该类型中。

当我们要扩展某个类的功能时,有以下几种方法:一是直接修改类的代码,这可能会导致向后兼容的破坏(不符合开闭原则)。一是派生子类,但这增加了维护的工作量,而且对于结构和密封类根本不能这么做。扩展方法允许我们在不创建子类,不更改类型本身的情况下,仍然可以修改类型。

扩展方法必须定义于静态的类型中,且所有的扩展方法必须是静态的。还是那句话,当你了解了类型对象时,你就很自然的理解了为何扩展方法必须是静态的。(它自类型对象被创建时就应当在对象的方法表中)

扩展方法的第一个输入参数要加上this(第一个参数的类型表示被扩展的类型)。扩展方法必须至少要有一个输入参数。

被扩展的类型的所有子类自动获得该扩展方法。

当你的工程内有特定逻辑,且其基于一个比较普遍的类时,考虑使用扩展方法。如果你想为类型添加一些成员,但又不能更改类型本身(因为不属于你)时,考虑使用扩展方法。例如当你需要频繁判断字符串是否为Email时,你可以扩展String类,将这个判断方法单独置于一个叫做StringExtension的类型中,方便管理。之后你就可以通过调用String.IsEmail来方便的使用这个方法了。

C#中提供了两个特别醒目的类:Enumerable和Queryable。两者都在System.Linq命名空间中。在这两个类中,含有许许多多的扩展方法。Enumerable的大多数扩展的是IEnumerable<T>,Queryable的大多数扩展的是IQueryable<T>。它们赋予了集合强大的查询能力,共同构成了LINQ的重要基础。

什么是闭包(Closure)?C#如何实现一个闭包?

闭包是一种语言特性,它指的是某个函数获取到在其作用域外部的变量,并可以与之互动。Closure这个单词显然来自动词close,有点动词名词化的意思。

通过匿名函数或者lambda表达式,我们可以实现一个简单的闭包:

static void Main(string[] args)
        {
            //外部变量
            var i = 0;
            //lambda表达式捕获外部变量
            //在外部变量的作用域内声明了一个方法
            MethodInvoker m = () =>
            {
                //使用外部变量
                i = i + 1;
            };

            m.Invoke();
            //打印出1
            Console.WriteLine(i);
        }

此处函数和来自外部的变量i进行了互动。

匿名函数(Anonymous Function)

匿名函数出现于C# 2.0,它允许在一个委托实例的创建位置内联地指定其操作。

例如我们可以这样写:

            Compare(c1, c2, delegate(Circle a, Circle b)
            {
                if (a.Radius > b.Radius) return 1;
                if (a.Radius < b.Radius) return -1;
                return 0;
            });

匿名方法的语法:先是一个delegate关键字,再是参数(如果有的话),随后是一个代码块,定义了对委托实例的操作。逆变性不适用于匿名方法,必须指定和委托类型完全匹配的参数类型(在本例中是两个Circle类型)。

通过在匿名方法中加入return来获得返回值。.NET 2中很少有委托有返回值(因为多个委托形成委托链之后,前面的返回值会被后面的覆盖),但LINQ中大部分委托都有返回值(通过Func泛型委托)。

使用匿名方法的主要好处是:不需要为一个函数命名,尤其是那种只用一次的函数,或者很短很简单的函数。当你了解了lambda表达式之后,就会发现在linq中,到处都是lambda表达式,而里面其实都是匿名函数(即委托)。如果我们在频繁使用linq的过程中,每次都要在外部建立一个函数,那代码的体积将会大大增加。

另外匿名函数还有很重要的一点,就是自动形成闭包。匿名函数内定义的变量称为匿名函数的局部变量,和普通函数不同的是,匿名函数除了可以使用局部变量,传入的变量之外,还可以使用捕获变量。当外部的变量被匿名函数在函数方法中使用时,称为该变量被捕获(即它成为了一个捕获变量)。

捕获的是变量的实例而不是值,也就是说,在匿名函数内的捕获变量和外部的变量是同一个。当变量被捕获时,值类型的变量自动“升级”,变成一个密封类。创建委托实例不会导致执行。

捕获变量(Captured Variable)的作用

捕获变量可以方便我们在创建匿名方法(或委托)时,获得所需要的变量。例如如果你有一个整型的列表,并希望写一个匿名方法筛选出小于某数limit的另一个列表,此时如果没有捕获变量,在匿名方法中我们就只能硬编码Limit的值,或者使用原始的委托,将变量传入委托的目标方法。

        static IEnumerable<int> Filter(List<int> aList, int limit)
        {
            //lambda表达式捕获外部变量Limit
            return aList.Where(a => a < limit);
        }

捕获变量的生存期

只要还有委托引用这个捕获变量,它就会一直存在。不管这个捕获变量是值类型还是引用类型,编译器会为其生成一个额外的类。

public delegate void MethodInvoker();
        static void Main(string[] args)
        {
            MethodInvoker m = CreateDelegate();
            //由于有委托引用a,a将会一直存在
            //捕获变量a不再位于栈上,编译器将其视为一个额外的类
            //CreateDelegate方法拥有对这个额外的类的一个实例的引用
            //当委托被回收之前,不会回收这个额外的类
            m();
        }

        static MethodInvoker CreateDelegate()
        {
            int a = 1;
            MethodInvoker m = () =>
            {
                Console.WriteLine(a);
                a++;
            };
            m();
            return m;
        }

打印出1和2。输出1是因为在调用CreateDelegate时,变量a是可用的。当CreateDelegate返回之后,调用m,a仍然是可用的,并没有随之消失。由于被捕获而形成闭包,a由一个栈上的值类型变成了引用类型。编译器生成了一个额外的密封类(名字是比较没有可读性的,例如c__DisplayClass1),它拥有一个成员a和一个方法,该方法内部的代码就是MethodInvoker中的代码。

CreateDelegate持有一个类型c__DisplayClass1的引用,所以它一直都能使用c__DisplayClass1中的成员a。

    internal class Program
    {
        public delegate void MethodInvoker();

        [CompilerGenerated]
        private sealed class <>c__DisplayClass1
        {
            public int a;

            public void <CreateDelegate>b__0()
            {
                Console.WriteLine(this.a);
                this.a++;
            }
        }

        private static void Main(string[] args)
        {
            Program.MethodInvoker methodInvoker = Program.CreateDelegate();
            methodInvoker();
            Console.ReadKey();
        }

        private static Program.MethodInvoker CreateDelegate()
        {
            Program.<>c__DisplayClass1 <>c__DisplayClass = new Program.<>c__DisplayClass1();
            <>c__DisplayClass.a = 1;
            Program.MethodInvoker methodInvoker = new Program.MethodInvoker(<>c__DisplayClass.<CreateDelegate>b__0);
            methodInvoker();
            return methodInvoker;
        }
    }

面试题:共享和非共享的捕获变量

在闭包和for循环一起使用时,如果多个委托捕捉到了同一个变量,则会有两种情况:捕捉到了同一个变量仅有的一个实例,和捕捉到同一个变量,但每个委托拥有自己的一个实例。

        static void Main()
        {
            int copy;
            List<Action> actions = new List<Action>();
            for (int counter = 0; counter < 10; counter++)
            {
                //只有一个变量copy,它在循环开始之前已经创建
                //所有的委托共享这个变量
                copy = counter;
                //创建委托时不会执行
                actions.Add(() => Console.WriteLine(copy));
            }
            foreach (Action action in actions)
            {
                //执行委托时打印copy当前的值
                //copy当前的值是9
                action();
            }
            Console.ReadKey();
        }

在这个例子中,捕获变量是copy,它只有一个实例(它的定义在外面,被捕获之后,自动升级为引用类型),所有委托共享这个实例。最后打印出10个9。

        static void Main()
        {
            int copy;
            List<Action> actions = new List<Action>();
            for (int counter = 0; counter < 10; counter++)
            {               
                copy = counter;
                //现在有十个内部变量,每个委托有一个实例,不同委托拥有的实例值是不同的
                //从而委托可以输出0-9
                int copy1 = copy;
                //创建委托时不会执行
                actions.Add(() => Console.WriteLine(copy1));
            }
            foreach (Action action in actions)
            {
                //执行委托时打印copy1的值
                action();
            }
            Console.ReadKey();
        }

使用内部变量解决多个委托共享一个捕获变量实例的问题。下面的代码中,包含了上面所说的两种情况,可以思考下最终的打印结果:

        static void Main(string[] args)
        {
            var list = new List<MethodInvoker>();
            for (int index = 0; index < 5; index++)
            {
                var counter = index*10;

                list.Add(delegate
                {
                    Console.WriteLine("{0}, {1}", counter, index);
                    counter++;    
                });
            }

            list[0]();
            list[1]();
            list[2]();
            list[3]();
            list[4]();

            list[0]();
            list[0]();
            list[0]();

            Console.ReadKey();
        }

其中循环内部建立了五个MethodInvoker。它们共享一个变量index的实例,但各自有自己的变量counter的实例。所以最终打印的结果中,index的值将总是5,而counter的值则每次都不同。

最后额外执行了第一个委托三次,此时counter的值会使用第一次,第一个委托运行之后counter的值,故会打出1,之后打印2,3同理。如果你额外执行第二个委托一次,将会打出11。这充分说明了每个委托都持有一个counter的实例,且它们是相互独立的。而无论执行任意一个委托多少次,index的值都是5。

foreach循环中捕获变量的变化

在C# 5中,foreach循环的行为变了,不会再出现多个委托共享一个变量的行为。所以我们即使不声明内部变量,方法也会打印出令人容易理解的结果:

 

        static void Main()
        {
            List<string> values = new List<string> {"a", "b", "c"};
            var actions = new List<Action>();
            foreach (string s in values)
            {
                //匿名方法捕获变量s
                //类比for循环最后的10个9,s最后的值是c
                //理论上会打印出三个c
                //但在c# 5中,会打印出a,b,c
                actions.Add(() => Console.WriteLine(s));
            }
            foreach (Action action in actions)
            {
                action();
            }
            Console.ReadKey();
        }

但对于for语句,行为和之前一样,仍然需要注意捕获变量被共享的问题。

posted @ 2016-09-09 10:25  Due勤奋的猪  阅读(4343)  评论(7编辑  收藏  举报