C# in Depth
文章目录
The Changing Face of C# Development
A Simple Data Type
Read-only properties and Weakly typed collections (C# 1)
|
|
Strongly typed collections and private setters (C# 2)
|
|
Automatically implemented properties and simpler initialization (C# 3)
|
|
Named arguments for clear initialization code (C# 4)
|
|
Sorting and Filtering
Sorting an ArrayList using IComparer (C# 1)
|
|
Sorting a List
|
|
Sorting a List
|
|
Sorting using Comparison
|
|
Ordering a List
|
|
You’re not actually sorting the list “in place” anymore, just retrieving the contents of the list in a particular order. Sometimes you’ll need to change the actual list; sometimes an ordering without any other side effects is better.
Querying collections
Looping, testing, printing out (C# 1)
|
|
Separating testing from printing (C# 2)
|
|
Separating testing from printing redux (C# 2)
|
|
Testing with a lambda expression (C# 3)
|
|
Handling An Absence of Data
In some cases, you may not know the price. If decimal were a reference type, you could just use null to represent the unknown price, but since it’s a value type, you can’t. .NET 2.0 makes matters a lot simpler by introducing the Nullable
|
|
The constructor parameter changes to decimal? , and then you can pass in null as the argument, or say Price = null; within the class. The meaning of the null changes from “a special reference that doesn’t refer to any object” to “a special value of any nullable type representing the absence of other data,” where all reference types and all Nullable
To check whether a price is known, you can compare it with null or use the HasValue property, so to show all the products with unknown prices in C# 3, you’d write the following code.
|
|
LINQ
LINQ (Language-Integrated Query) is at the heart of the changes in C# 3. LINQ is all about queries—the aim is to make it easy to write queries against multiple data sources with consistent syntax and features, in a readable and composable fashion.
First steps with query expressions: filtering a collection
|
|
I’ve snuck in one extra feature here—implicitly typed local variables, which are declared using the var contextual keyword. In this case, the type of filtered is IEnumerable
Joining, filtering, ordering, and projecting (C# 3)
|
|
Querying XML
Suppose that instead of hardcoding your suppliers and products, you’d used the following XML file:
|
|
Complex processing of an XML file with LINQ to XML (C# 3)
|
|
LINQ to SQL
Applying a query expression to a SQL database (C# 3)
|
|
The above code issues a database request, which is basically the query translated into SQL. Even though you’ve expressed the query in C# code, it’s been executed as SQL.
COM and dynamic typing
There are various ways of making data available to Excel, but using COM to control it gives you the most power and flexibility.
Saving data to Excel using COM (C# 4)
|
|
The SaveAs call uses named arguments.
Running IronPython and extracting properties dynamically (C# 4)
|
|
Both products and product are declared to be dynamic.
asynchronous code without the heartache
The following listing shows a single method that handles a button click in a Windows Forms application and displays information about a product, given its ID.
|
|
The method has the new async modifier, and there are two await expressions. It starts off performing lookups on both the product directory and warehouse to find out the product details and current stock. The method then waits until it has the product information, and quits if the directory has no entry for the given ID.
Both the product and stock lookups are asynchronous—they could be database operations or web service calls. It doesn’t matter—when you await the results, you’re not actually blocking the UI thread, even though all the code in the method runs on that thread. When the results come back, the method continues from where it left off.
Dissecting the .NET Platform
The language of C# is defined by its specification, which describes the format of C# source code, including both syntax and behavior. It doesn’t describe the platform that the compiler output will run on, beyond a few key points where the two interact. In theory, any platform that supports the required features could have a C# compiler targeting it. A runtime could interpret the output of a C# compiler, or convert it all to native code in one step rather than JIT-compiling it.
The runtime aspect of the .NET platform is the relatively small amount of code that’s responsible for making sure that programs written in Intermediate Language ( IL ) execute according to the Common Language Infrastructure ( CLI ) specification. The runtime part of the CLI is called the Common Language Runtime ( CLR ).
Libraries provide code that’s available to your programs. The framework libraries in .NET are largely built as IL themselves, with native code used only where necessary. This is a mark of the strength of the runtime: your own code isn’t expected to be a second-class citizen—it can provide the same kind of power and performance as the libraries it utilizes.
Core foundations: building on C# 1
Delegates
Essentially, delegates provide a level of indirection: instead of specifying behavior to be executed immediately, the behavior can somehow be “contained” in an object. That object can then be used like any other, and one operation you can perform with it is to execute the encapsulated action. A delegate in C# acts like your will does in the real world—it allows you to specify a sequence of actions to be executed at the appropriate time. Delegates are typically used when the code that wants to execute the actions doesn’t know the details of what those actions should be. For instance, the only reason why the Thread class knows what to run in a new thread when you start it is because you provide the constructor with a ThreadStart or ParameterizedThreadStart delegate instance.
In order for a delegate to do anything, four things need to happen:
- The delegate type needs to be declared.
|
|
The code says that if you want to create an instance of StringProcessor, you’ll need a method with one parameter and a void return type. It’s important to understand that StringProcessor really is a type, deriving from System.MulticastDelegate, which in turn derives from System.Delegate. It has methods, you can create instances of it and pass around references to instances, the whole works. Delegates can be misunderstood because the word delegate is often used to describe both a delegate type and a delegate instance.
- The code to be executed must be contained in a method.
Consider these method signatures as candidates to be used for a StringProcessor instance:
|
|
The first method has everything right, so you can use it to create a delegate instance. The last method would make sense to be able to use it for an instance of StringProcessor, but in C# 1 the delegate must have exactly the same parameter types. C# 2 changes this situation.
- A delegate instance must be created.
|
|
When the action is a static method, you only need to specify the type name. When the action is an instance method, you need an instance of the type. This object is called the target of the action, and when the delegate instance is invoked, the method will be called on that object. It’s worth being aware that a delegate instance will prevent its target from being garbage collected if the delegate instance itself can’t be collected.
- The delegate instance must be invoked.
proc1("Hello"); Compiles to…
proc1.Invoke("Hello"); Which at execution time invokes…
PrintString("Hello");
Using delegates in a variety of simple ways:
|
|
Why not just call the methods directly? Often the object that first creates a delegate instance is still alive and well when the delegate instance is invoked. Instead, it’s about specifying some code to be executed at a particular time, when you may not be able (or may not want) to change the code that’s running at that point. If I want something to happen when a button is clicked, I don’t want to have to change the code of the button—I just want to tell the button to call one of my methods, which will take the appropriate action. It’s a matter of adding a level of indirection, as so much of object-oriented programming is. As you’ve seen, this adds complexity but also flexibility.
Combining and removing delegates
A delegate instance actually has a list of actions associated with it called the invocation list. The static Combine and Remove methods of the System.Delegate type are responsible for creating new delegate instances by respectively splicing together the invocation lists of two delegate instances or removing the invocation list of one delegate instance from another.
DELEGATES ARE IMMUTABLE. Once you’ve created a delegate instance, nothing about it can be changed. This makes it safe to pass around references to delegate instances and combine them with others without worrying about consistency, thread safety, or anyone trying to change their actions. This is like strings, which are also immutable, and Delegate.Combine is just like String.Concat —they both combine existing instances together to form a new one without changing the original objects at all. Note that if you ever try to combine null with a delegate instance, the null is treated as if it were a delegate instance with an empty invocation list.
You’ll rarely see an explicit call to Delegate.Combine and Delegate.Remove in C# code, usually the - and -= , the + and += operators are used. Delegate.Remove(source, value) creates a new delegate whose invocation list is the one from source , with the list from value having been removed. If the result would have an empty invocation list, null is returned.
When a delegate instance is invoked, all its actions are executed in order. If the delegate’s signature has a nonvoid return type, the value returned by Invoke is the value returned by the last action executed. It’s rare to see a nonvoid delegate instance with more than one action in its invocation list because it means the return values of all the other actions are never seen unless the invoking code explicitly executes the actions one at a time, using Delegate.GetInvocationList to fetch the list of actions.
If any of the actions in the invocation list throws an exception, that prevents any of the subsequent actions from being executed.
A brief diversion into events
It’s helpful to think of events as being similar to properties. To start with, both of them are declared to be of a certain type—an event is forced to be a delegate type. When you subscribe to or unsubscribe from an event, it looks like you’re using a field whose type is a delegate type, with the += and -= operators. Though, you’re actually calling methods (add and remove).
Field-like events make the implementation of all of this much simpler to look at—a single declaration and you’re done. The compiler turns the declaration into both an event with default add/remove implementations and a private field of the same type. Code inside the class sees the field; code outside the class only sees the event. This makes it look as if you can invoke an event, but what you actually do to call the event handlers is invoke the delegate instance stored in the field.
Summary of delegates
- Delegates encapsulate behavior with a particular return type and set of parameters, similar to a single-method interface.
- The type signature described by a delegate type declaration determines which methods can be used to create delegate instances, and the signature for invocation.
- Creating a delegate instance requires a method and (for instance methods) a target to call the method on.
- Delegate instances are immutable.
- Delegate instances each contain an invocation list—a list of actions.
- Delegate instances can be combined with and removed from each other.
- Events aren’t delegate instances—they’re just add/remove method pairs (think property getters/setters).
Type system characteristics
C# 1’s type system is static, explicit, and safe.
C# 1 is statically typed: each variable is of a particular type, and that type is known at compile time. Only operations that are known for that type are allowed, and this is enforced by the compiler.
The alternative to static typing is dynamic typing, which can take a variety of guises. The essence of dynamic typing is that variables just have values—they aren’t restricted to particular types, so the compiler can’t perform the same sort of checks. Instead, the execution environment attempts to understand expressions in an appropriate manner for the values involved.
The distinction between explicit typing and implicit typing is only relevant in statically typed languages. With explicit typing, the type of every variable must be explicitly stated in the declaration. Implicit typing allows the compiler to infer the type of the variable based on its use. For example, implicit typing: var s = "hello";, explicit typing: string s = "hello";.
|
|
If you compile and run it with a simple argument of “hello”, you’ll see a value of 1819043176 —at least on a little-endian architecture with a compiler treating int as 32 bits and char as 8 bits, and where text is represented in ASCII or UTF-8. The code is treating the char pointer as an int pointer, so dereferencing it returns the first 4 bytes of text, treating them as a number. In fact, this tiny example is tame compared with other potential abuses—casting between completely unrelated structs can easily result in total mayhem.
Fortunately, none of this occurs in C#. Yes, there are plenty of conversions available, but you can’t pretend that data for one particular type of object is actually data for a different type.
When is C# 1’s type system not rich enough?
Broadly speaking, three kinds of collection types are built into .NET 1.1:
- Arrays—strongly typed—in both the language and the runtime
- Weakly typed collections in the System.Collections namespace
- Strongly typed collections in the System.Collections.Specialized namespace
Arrays are strongly typed, so at compile time you can’t set an element of a string[] to be a FileStream, for instance. But reference type arrays also support covariance, which provides an implicit conversion from one type of array to another, as long as there’s a conversion between the element types.
|
|
If you run it, you’ll see that an ArrayTypeMismatchException is thrown. This is because the conversion from string[] to object[] returns the original reference—both strings and objects refer to the same array. The array itself knows it’s a string array and will reject attempts to store references to nonstrings.
Let’s compare this with the situation that weakly typed collections, such as ArrayList and Hashtable. The API of these collections uses object as the type of keys and values. When you write a method that takes an ArrayList, for example, there’s no way of making sure at compile time that the caller will pass in a list of strings. You can document it, and the type safety of the runtime will enforce it if you cast each element of the list to string , but you don’t get compile-time type safety.
Finally, consider strongly typed collections, such as StringCollection. These provide a strongly typed API , so you can be confident that when you receive a StringCollection as a parameter or return value, it’ll only contain strings, and you don’t need to cast when fetching elements of the collection. It sounds ideal, but there are two problems. First, it implements IList, so you can still try to add nonstrings to it. Second, it only deals with strings.
ICloneable is one of the simplest interfaces in the framework. It has a single method, Clone, which should return a copy of the object that the method is called on. Now, leaving aside the issue of whether this should be a deep or shallow copy, let’s look at the signature of the Clone method:
|
|
It would make sense to be able to override the method with a signature that gives a more accurate description of what the method actually returns. For example, in a Person class it’d be nice to be able to implement ICloneable with
|
|
This feature is called return type covariance but, unfortunately, interface implementation and method overriding don’t support it. Instead, the normal workaround for interfaces is to use explicit interface implementation to achieve the desired effect:
|
|
The mirror image of this situation also occurs with parameters, where if you had an interface or virtual method with a signature of, say, void Process(string x) , it’d seem logical to be able to implement or override the method with a less demanding signature, such as void Process(object x) . This is called parameter type contravariance; it’s just as unsupported as return type covariance.
Summary of type system characteristics
- C# 1 is statically typed—the compiler knows what members to let you use.
- C# 1 is explicit—you have to state the type of every variable.
- C# 1 is safe—you can’t treat one type as if it were another unless there’s a genuine conversion available.
- Static typing doesn’t allow a single collection to be a strongly typed list of strings or list of integers without a lot of code duplication for different element types.
- Method overriding and interface implementation don’t allow covariance or contravariance.
Value types and reference types
- classes (class) are reference types, structures (struct) are value types.
- array types (int[]) are reference types, even if the element type is a value type.
- Enumerations (enum) are value types.
- Delegate types (delegate) are reference types.
- Interface types (interface) are reference types, but they can be implemented by value types.
The value of a value type expression is the value, plain and simple. The value of a reference type expression, though, is a reference—it’s not the object that the reference refers to. The value of the expression String.Empty is not an empty string—it’s a reference to an empty string. To demonstrate this further, consider a Point type that stores two integers, x and y. This type could be implemented as either a struct or a class.
|
|
When Point is a reference type, that value is a reference: both p1 and p2 refer to the same object. When Point is a value type, the value of p1 is the whole of the data for a point—the x and y values. Assigning the value of p1 to p2 copies all of that data.
Another difference between the two kinds of type is that value types can’t be derived from. One consequence of this is that the value doesn’t need any extra information about what type that value actually is. Compare that with reference types, where each object contains a block of data at the start identifying the type of the object, along with some other information. You can never change the type of an object—when you perform a simple cast, the runtime just takes a reference, checks whether the object it refers to is a valid object of the desired type, and returns the reference if it’s valid or throws an exception otherwise. The reference itself doesn’t know the type of the object, so the same reference value can be used for multiple variables of different types. For instance, consider the following code:
|
|
STRUCTS ARE LIGHTWEIGHT CLASSES — Not Right, the value types and the reference types both can have methods, the reference types have better performance when passing parameter.
REFERENCE TYPES LIVE ON THE HEAP, VALUE TYPES LIVE ON THE STACK — The first part is correct—an instance of a reference type is always created on the heap. A variable’s value lives wher- ever it’s declared, so if you have a class with an instance variable of type int , that variable’s value for any given object will always be where the rest of the data for the object is—on the heap. Only local variables (variables declared within methods) and method parameters live on the stack. In C# 2 and later, even some local variables don’t really live on the stack.
OBJECTS ARE PASSED BY REFERENCE IN C# BY DEFAULT — If you pass a variable by reference, the method you’re calling can change the value of the caller’s variable by changing its parameter value. Now, remember that the value of a reference type variable is the reference, not the object itself. You can change the contents of the object that a parameter refers to without the parameter itself being passed by reference. For instance, the following method changes the contents of the StringBuilder object in question, but the caller’s expression will still refer to the same object as before:
|
|
When this method is called, the parameter value (a reference to a StringBuilder ) is passed by value. If you were to change the value of the builder variable within the method—for example, with the statement builder = null; —that change wouldn’t be seen by the caller, contrary to the myth.
Parameter passing in C#
In .NET (and therefore C#) there are two main sorts of type: reference types and value types. Classes, delegates and interfaces are reference types; structs and enums are value types. They act differently, and a lot of confusion about parameter passing is really down to people not properly understanding the difference between them. A reference type is a type which has as its value a reference to the appropriate data rather than the data itself. For instance, consider the following code:
|
|
Here, we declare a variable sb, create a new StringBuilder object, and assign to sb a reference to the object. The value of sb is not the object itself, it’s the reference.
|
|
We assign to second the value of first. This means that they both refer to the same object. If we modify the contents of the object via another call to first.Append, that change is visible via second:
|
|
They are still, however, independent variables themselves. Changing the value of first to refer to a completely different object (or indeed changing the value to a null reference) doesn’t affect second at all, or the object it refers to:
|
|
To reiterate: class types, interface types, delegate types and array types are all reference types.
While reference types have a layer of indirection between the variable and the real data, value types don’t. Variables of a value type directly contain the data. Assignment of a value type involves the actual data being copied. Take a simple struct, for example:
|
|
Wherever there is a variable of type IntHolder, the value of that variable contains all the data. Simple types (such as float, int, char), enum types and struct types are all value types.
Note that many types (such as string) appear in some ways to be value types, but in fact are reference types. These are known as immutable types. This means that once an instance has been constructed, it can’t be changed.
There are four different kinds of parameters in C#: value parameters (the default), reference parameters (which use the ref modifier), output parameters (which use the out modifier), and parameter arrays (which use the params modifier). You can use any of them with both value and reference types. When you hear the words “reference” or “value” used (or use them yourself) you should be very clear in your own mind whether you mean that a parameter is a reference or value parameter, or whether you mean that the type involved is a reference or value type.
By default, parameters are value parameters. This means that a new storage location is created for the variable in the function member declaration. If you change that value, that doesn’t alter any variables involved in the invocation. Reference parameters don’t pass the values of the variables used in the function member invocation - they use the variables themselves. Rather than creating a new storage location for the variable in the function member declaration, the same storage location is used, so the value of the variable in the function member and the value of the reference parameter will always be the same.
|
|
Boxing and unboxing
The value of a reference type variable is always a reference. The value of a value type variable is always a value of that type.
|
|
boxing: the runtime creates an object (on the heap—it’s a normal object) that contains the value ( 5 ). The value of o is then a reference to that new object. The value in the object is a copy of the original value—changing the value of i won’t change the value in the box at all. The third line performs the reverse operation—unboxing. You have to tell the compiler which type to unbox the object as, and if you use the wrong type, an InvalidCastException is thrown. Again, unboxing copies the value that was in the box.
The only remaining problem is knowing when boxing and unboxing occur. Unboxing is usually obvious, because the cast is present in the code. Boxing can be more subtle. You’ve seen the simple version, but it can also occur if you call the ToString , Equals , or GetHashCode methods on the value of a type that doesn’t override them, or if you use the value as an interface expression—assigning it to a variable whose type is an interface type or passing it as the value for a parameter with an interface type. For example, the statement IComparable x = 5; would box the number 5. A single box or unbox operation is cheap, but if you perform hundreds of thousands of them, you not only have the cost of the operations, but you’re also creating a lot of objects, which gives the garbage collector more work to do.
Summary of value types and reference types
- The value of a reference type expression (a variable, for example) is a reference, not an object.
- References are like URLs—they’re small pieces of data that let you access the real information.
- The value of a value type expression is the actual data.
- There are times when value types are more efficient than reference types, and vice versa.
- Reference type objects are always on the heap, but value type values can be on either the stack or the heap, depending on context.
- When a reference type is used as a method parameter, by default the argument is passed by value, but the value itself is a reference.
- Value type values are boxed when reference type behavior is needed; unboxing is the reverse process.
Features related to delegates
Improvements in delegate instantiation brought in by C# 2
|
|
The first part of the main code is just C# 1 code, kept for comparison. The remaining delegates all use new features of C# 2. C# 3 provides special syntax for instantiating delegate types, using lambda expressions.
|
|
Features related to the type system
The primary new feature in C# 2 regarding the type system is the inclusion of generics. C# 3 introduced a wealth of new concepts in the type system, most notably anonymous types, implicitly typed local variables, and extension methods. Demonstration of anonymous types and implicit typing:
|
|
The first two lines each show implicit typing (the use of var ) and anonymous object initializers (the new {…} bit), which create instances of anonymous types.
The first is that C#3 is still statically typed. The C# compiler has declared jon and tom to be of a particular type, just as normal, and when you use the properties of the objects, they’re normal properties—no dynamic lookup is going on. The second point is that we haven’t created two different anonymous types here. The variables jon and tom both have the same type because the compiler uses the property names, types, and order to work out that it can generate just one type and use it for both statements.
Extension methods are also there for the sake of LINQ but can be useful outside it. Extension methods also let you appear to add methods with implementations to interfaces, and LINQ relies on this heavily, allowing calls to all kinds of methods on IEnumerable
Dynamic typing in C# 4
|
|
By declaring the variable o as having a static type of dynamic, the compiler handles almost everything to do with o differently, leaving all the binding decisions (such as what Length means) until execution time.
Features related to value types
|
|
The list of enhancements is smaller this time, but they’re important features in terms of both performance and elegance of expression: Generics, Nullable types.
Parameterized typing with generics
文章作者 huijian142857
上次更新 2016-09-29