The book "Effective C# Second Edition" originally has 50 principles, but these 50 principles are written for the C# language itself and .NET. While reading the book, I found that some principles are not applicable to the use of C# in the Mono version of Unity. Therefore, when summarizing the reading notes, I omitted the inapplicable principles and distilled the applicable ones, summarizing 21 principles that make up the content of this article.
It should be noted that the guideline numbers might be somewhat disjointed, as only the applicable ones from the book have been selected. For ease of reading, the numbers have been rearranged in this article. For those guidelines whose numbers differ from the original book after rearrangement, the corresponding original book number is indicated at the end of the summary.
Similarly, as a summary-style article, the content of each guideline is highly condensed, which might be difficult to understand or require more effort to grasp. If you come across any parts that are difficult to understand, I recommend reading the original book, and examining the various code and examples provided in the book to facilitate better understanding and mastery.
● Property has always been a distinctive feature in the C# language. It allows exposing data members as part of the public interface while still providing encapsulation in an object-oriented environment. The property language element enables you to access data members as if they were public, but the underlying functionality of the property is actually implemented using methods.
● With property, you can easily add checking mechanisms in the get and set code segments.
Note that since property is implemented using methods, it possesses all the language features that methods have.
1.Adding multi-threading support to property is very convenient. You can strengthen the implementation of get and set accessors to provide synchronized data access.
2.Property can be defined as virtual.
3.Property can be extended to be abstract.
4.Generic versions of property types can be used.
5. Property can also be defined as interfaces.
6.Since the methods for implementing get and set access are two separate methods, in C# 2.0 and later, you can define different access levels for them to better control the visibility of class members.
7.To maintain consistency with multi-dimensional arrays, we can create multi-dimensional indexers that use the same or different types on different dimensions.
When exposing data in the public or protected interfaces of a type, properties should be used. If possible, indexers should also be used to expose sequences or dictionaries. Investing more time in using properties now will make future maintenance much more manageable.
There are two different versions of constants in C#: readonly and const.
It is recommended to use readonly rather than const. Although const is slightly faster, it is not as flexible as readonly. Const should only be used in cases where performance is extremely sensitive, and the value of the constant will never change between different versions.
The difference between readonly and consts lies in their access methods, as readonly values are resolved at runtime:
- The value of a const will be directly replaced by its actual value in the generated code during compilation.
- The value of a readonly is evaluated during the program's execution.
- During runtime, the generated IL code will reference the readonly variable itself, rather than the value of the variable.
This difference leads to the following rules:
● Const can only be used for numeric and string values.
● Readonly can be of any type. Readonly must be initialized in the constructor or initializer, as it cannot be modified after the constructor has executed. You can set a readonly value to a DateTime structure, but you cannot specify a const value as DateTime.
● Readonly can be used to store instance constants, holding different values for each instance of a class. Const, on the other hand, is static constant.
● Sometimes you need a value to be determined at compile time, in which case it is best to use runtime readonly.
● Readonly should be used for marking version numbers, as their values will change with the release of each different version.
● The only advantage of const over readonly is performance. Using known constant values is slightly faster than accessing readonly, but the efficiency improvement is negligible.
In summary, when the compiler must obtain a definite value, const must be used. For example, attribute parameters, enumeration definitions, and values that will not change between different version releases. In all other cases, the more flexible readonly constant should be chosen whenever possible.
● In C#, the usage of the 'is' and 'as' operators can be summarized as follows:
is: Checks if an object is compatible with a specified type and returns a Boolean value, never throwing an exception.
as: Functions like explicit type casting, but never throws an exception; if the conversion fails, it returns null.
● Use the 'as' operator whenever possible, as it is safer and more efficient compared to explicit type casting.
● The 'as' operator returns null when the conversion fails and when the object being converted is null. Therefore, when using 'as' for conversion, you only need to check if the returned reference is null.
● Both the 'as' and 'is' operators do not perform any user-defined conversions. They can only successfully convert when the runtime type matches the target type and do not create new objects during conversion.
● The 'as' operator is not valid for value types. In this case, you can use 'is' with explicit type casting for conversion.
● Use the 'is' operator only when you cannot use 'as' for conversion. Otherwise, 'is' is redundant.
● The #if/#endif directives can be easily misused, making the code harder to understand and more difficult to debug. C# provides a Conditional attribute to address this issue. Using the Conditional attribute allows you to split functions so that they are only compiled and become part of the class when certain environment variables are defined or a specific value is set. The most common use of the Conditional attribute is to turn a block of code into a debugging statement.
● The Conditional attribute can only be applied to an entire method. Additionally, any method using the Conditional attribute must return void. The Conditional attribute cannot be applied to code blocks within a method or to methods with return values. However, methods with the Conditional attribute can accept any number of reference type parameters.
● The Intermediate Language (IL) generated using the Conditional attribute is more efficient than that generated using #if/#endif. Also, limiting it to the function level allows for clearer separation of conditional code, further ensuring good code structure.
● In C#, there are two types that can be created: Reference Equality and Value Equality. If two reference type variables point to the same object, they are considered "reference equal". If two value type variables have the same type and contain the same content, they are considered "value equal". This is why there are so many methods for equality comparisons.
● When creating our own types (whether classes or structs), we should define the meaning of "equality" for the type. C# provides four different functions to determine if two objects are "equal".
1) public static bool ReferenceEquals(object left, object right); Determines whether the object identity of two different variables is equal. Whether comparing reference types or value types, the basis for the judgment is object identity, not object content.
2) public static bool Equals(object left, object right); Used to determine if the runtime types of two variables are equal.
3) public virtual bool Equals(object right); Used for overloading.
4) public static bool operator ==(MyClass left, MyClass right); Used for overloading.
● You should not override the Object.ReferenceEquals() static method and the Object.Equals() static method, as they have already perfectly completed the required work, providing the correct judgment that is independent of the runtime-specific type. For value types, we should always override the Object.Equals() instance method and operator==( ), to provide more efficient equality comparisons. For reference types, only override the Object.Equals() instance method when you believe that the meaning of equality is not object identity. When overriding Equals(), also implement IEquatable<T>.
PS: This principle corresponds to Principle 6 in "Effective C# Second Edition".
● When using the GetHashCode() method, there are many pitfalls, so use it with caution. GetHashCode() function is only used in one place, that is, when defining the hash value of a key for hash-based collections, such as HashSet<T> and Dictionary<K,V> containers. For reference types, it can work normally, but the efficiency is very low. For value types, the implementation in the base class is sometimes incorrect. Moreover, it is impossible to write your own GetHashCode() that is both efficient and correct.
● In .NET, every object has a hash code, the value of which is determined by System.Object.GetHashCode().
● When implementing your own GetHashCode(), follow the three principles above:
1) If two objects are equal (defined by operation ==), they must generate the same hash code. Otherwise, such a hash code cannot be used to find objects in the container.
2) For any object A, A.GetHashCode() must remain unchanged.
3) For all inputs, the hash function should generate hash codes randomly among all integers. This way, the hash container can achieve sufficient efficiency improvement.
PS: This principle corresponds to Principle 7 in "Effective C# Second Edition".
Translating C# code into executable machine code requires two steps.
The C# compiler generates IL and places it in the assembly. Subsequently, JIT generates machine code for methods (or a group of methods if involving inlining) as needed. Short methods allow the JIT compiler to better amortize the cost of compilation. Short methods are also more suitable for inlining.
In addition to brevity, simplifying control flow is also important. The fewer control branches, the easier it is for the JIT compiler to find the most suitable variables to place in registers.
Therefore, the advantages of short methods are not only reflected in the readability of the code but also related to the efficiency of the program at runtime.
PS: This principle corresponds to Principle 11 in "Effective C# Second Edition".
Member initializers are the simplest way to ensure that all members in a type are initialized, regardless of which constructor is called. Initializers will be executed before all constructors. Using this syntax also ensures that you do not miss important initialization code when adding new constructors.
In summary, if all constructors need to initialize a certain member variable to the same value, an initializer should be used.
PS: This principle corresponds to Principle 12 in "Effective C# Second Edition".
● C# provides static initializers and static constructors specifically for initializing static member variables.
● A static constructor is a special function that is executed before all other methods and before variables or properties are first accessed. This function can be used to initialize static variables, implement singleton patterns, or perform any operations that must be performed before the class is available.
● Like instance initialization, initializer syntax can also be used instead of a static constructor. If you only need to allocate space for a static member, you might as well use initializer syntax. If you need more complex logic to initialize static member variables, you can use a static constructor.
● The most common reason for using a static constructor instead of a static initializer is to handle exceptions. When using a static initializer, we cannot catch exceptions ourselves. However, this can be done in a static constructor.
PS: This principle corresponds to Principle 13 in "Effective C# Second Edition".
● Writing constructors is often a repetitive task. If you find that multiple constructors contain the same logic, you can extract this logic into a common constructor. This can avoid code duplication and generate more efficient target code using constructor initializers.
● The C# compiler treats constructor initializers as a special syntax and removes duplicate variable initializers and duplicate base class constructor calls. This allows the final object to execute the least amount of code to ensure correct initialization.
● Constructor initializers allow one constructor to call another constructor. C# 4.0 added support for default parameters, which can also be used to reduce duplicate code in constructors. You can unify all constructors of a class into one and specify default values for all optional parameters. Other constructors call a constructor and provide different parameters.
PS: This principle corresponds to Principle 14 in "Effective C# Second Edition".
● GC can efficiently manage the memory used by applications. However, creating and destroying objects on the heap still takes time. If too many reference objects are created in a method, it will have a serious impact on the performance of the program.
Here are some rules to help you minimize GC's workload:
1) If a reference type (value type doesn't matter) local variable is used in frequently called routines, it should be promoted to a member variable.
2) Provide static objects for commonly used type instances.
3) Create immutable types of final values. For example, the += operator of the string class will create a new string object and return it, multiple uses will generate a lot of garbage, not recommended. For simple string operations, use string.Format. For complex string operations, use the StringBuilder class.
PS: This principle corresponds to Principle 16 in "Effective C# Second Edition".
● In C#, class corresponds to reference types, and struct corresponds to value types.
● C# is not C++, where all types can be defined as value types and create references when needed. C# is also not Java, where everything is a reference type. You must decide the behavior of the type at creation, which is quite important, as later changes may cause many disastrous problems.
● Value types cannot implement polymorphism, so their best use is for storing data. Reference types support polymorphism, so they are used to define the behavior of applications.
● In general, we are used to using class, so most of the created types are reference types. If the following points are confirmed, then a struct value type should be created:
1) Is the main responsibility of the type data storage?
2) Are the public interfaces of the type defined by accessing its data members' properties?
3) Are you sure that the type will never have derived types?
4) Are you sure that the type will never need polymorphic support?
● Use value types to represent underlying data storage types and reference types to encapsulate program behavior. This way, you can ensure that the data exposed by the class can be safely provided in a copy form, and you can also benefit from the memory performance improvement brought by stack storage and inline storage, and use standard object-oriented techniques to express application logic. If you are unsure about the future use of the type, you should choose a reference type.
PS: This principle corresponds to Principle 18 in "Effective C# Second Edition".
When creating custom enumeration values, make sure 0 is a valid option. If you define a flag, you can define 0 as a flag that does not select any state (such as None). That is, enumeration values used as markers (i.e., with Flags attribute added) should always set None to 0.
PS: This principle corresponds to Principle 19 in "Effective C# Second Edition".
Constant types make our code easier to maintain. Do not blindly create get and set accessors for every property in a type. For types whose purpose is to store data, try to ensure their constancy and atomicity as much as possible.
PS: This principle corresponds to Principle 20 in "Effective C# Second Edition".
On the premise that the type can complete its work, you should allocate the minimum visibility to the type as much as possible. That is, only expose what needs to be exposed. Try to use lower visibility classes to implement public interfaces. The lower the visibility, the fewer the code that can access your features, and the fewer the modifications that may occur in the future.
PS: This principle corresponds to Principle 21 in "Effective C# Second Edition".
● Understand the difference between abstract classes (abstract class) and interfaces (interface):
1) An interface is a contract-based design approach, and a type that implements a certain interface must implement the methods agreed upon in the interface. Abstract classes, on the other hand, provide a common abstraction for a group of related types. In other words, abstract classes describe what an object is, while interfaces describe how an object will behave.
2) Interfaces cannot contain implementations or any specific data members. However, abstract classes can provide some concrete implementations for derived classes.
3) Base classes describe and implement shared behavior among a group of related types. Interfaces define a set of atomic functions for other unrelated concrete types to implement.
● By understanding the differences between the two, we can create more expressive and adaptable designs. Use class hierarchies to define related types. Expose functionality through interfaces and allow different types to implement these interfaces.
PS: This principle corresponds to Principle 22 in "Effective C# Second Edition".
At first glance, implementing interfaces and overriding virtual methods seem to have no difference. In fact, there is a big difference between implementing interfaces and overriding virtual methods.
1) Interface methods declared by default are not virtual methods, so derived classes cannot override non-virtual interface members in the base class. To override, declare the interface method as virtual.
2) Base classes can provide default implementations for methods in the interface. Subsequently, derived classes can also declare that they have implemented the interface and inherit the implementation from the base class.
3) Implementing interfaces offers more choices than creating and overriding virtual methods. We can create sealed (sealed) implementations, virtual implementations, or abstract contracts for class hierarchies. We can also create sealed implementations and provide virtual methods for calling in the methods that implement the interface.
PS: This principle corresponds to Principle 23 in "Effective C# Second Edition".
In C#, callbacks are implemented using delegates, with the following main points:
1) Delegates provide us with type-safe callback definitions. Although most common delegate applications are related to events, this is not the entire situation for C# delegate applications. When there is a need for communication between classes and we expect a more loose coupling mechanism than what interfaces provide, delegates are the best choice.
2) Delegates allow us to configure targets at runtime and notify multiple client objects. A delegate object contains a reference to a method, which can be a static method or an instance method. That is, using delegates, we can communicate with one or more client objects that are connected at runtime.
3) Callbacks and delegates are so commonly used in C# that C# specifically provides a streamlined syntax for them in the form of lambda expressions.
4) Due to some historical reasons, all delegates in .NET are multicast delegates (multicast delegate). In the process of multicast delegate invocation, each target is called in sequence. The delegate object itself does not capture any exceptions. Therefore, any exceptions thrown by the target will end the delegate chain invocation.
PS: This principle corresponds to Principle 24 in "Effective C# Second Edition".
● Events provide a standard mechanism to notify listeners, and events in C# are actually a syntactic shortcut for implementing the observer pattern.
● Events are built-in delegates that provide type-safe method signatures for event handlers. Any number of client objects can register their handling functions on the event, and then process these events, without having to be given at the compiler, and events do not have to have subscribers to work properly.
● Using events in C# can reduce the coupling between the sender and the possible notification recipients, and the sender can be developed completely independently of the recipients.
PS: This principle corresponds to Principle 25 in "Effective C# Second Edition".
● If a reference type is exposed to the outside world through a public interface, the user of the object can bypass our defined methods and properties to change the internal structure of the object, which can lead to common errors.
● There are four different strategies to prevent the internal data structure of the type from being modified intentionally or unintentionally:
1) Value types. When client code accesses value type members through properties, the actual return is a copy of the value type object.
2) Constant types. Such as System.String.
3) Define interfaces. Restrict client access to internal data members within a set of functions.
4) Wrapper (wrapper). Provide a wrapper that only exposes the wrapper, thereby limiting access to the objects inside.
PS: This principle corresponds to Principle 26 in "Effective C# Second Edition".
● Using the new operator to modify class members can redefine non-virtual members inherited from the base class.
● The new modifier is only used to resolve conflicts between base class methods and derived class methods caused by base class upgrades.
● The new operator must be used with caution. If used indiscriminately, it can cause ambiguity in object method calls.
PS: This principle corresponds to Principle 33 in "Effective C# Second Edition".