You have used value types throughout this book; for example, int
is a value type. This chapter discusses not only using value types, but also defining custom value types. There are two categories of custom value types: structs and enums. This chapter discusses how structs enable programmers to define new value types that behave very similarly to most of the predefined types discussed in Chapter 2. The key is that any newly defined value types have their own custom data and methods. The chapter also discusses how to use enums to define sets of constant values.
All of the C# “built-in” types, such as bool
and decimal
, are value types, with the exception of string
and object
, which are reference types. Numerous additional value types are provided within the framework. It is also possible for developers to define their own value types.
To define a custom value type, you use a similar syntax as you would use to define class and interface types. The key difference in the syntax is that value types use the keyword struct
, as shown in Listing 8.1. Here we have a value type that describes a high-precision angle in terms of its degrees, minutes, and seconds. (A “minute” is one-sixtieth of a degree, and a second is one-sixtieth of a minute. This system is used in navigation because it has the nice property that an arc of one minute over the surface of the ocean at the equator is exactly one nautical mile.)
Begin 6.0
// Use keyword struct to declare a value type.
struct Angle
{
public Angle(int degrees, int minutes, int seconds)
{
Degrees = degrees;
Minutes = minutes;
Seconds = seconds;
}
// Using C# 6.0 read-only, automatically implememted properties.
public int Degrees { get; }
public int Minutes { get; }
public int Seconds { get; }
public Angle Move(int degrees, int minutes, int seconds)
{
return new Angle(
Degrees + degrees,
Minutes + minutes,
Seconds + seconds);
}
}
// Declaring a class--a reference type
// (declaring it as a struct would create a value type
// larger than 16 bytes.)
class Coordinate
{
public Angle Longitude { get; set; }
public Angle Latitude { get; set; }
}
This listing defines Angle
as a value type that stores the degrees, minutes, and seconds of an angle, either longitude or latitude. The resultant C# type is a struct.
Note that the Angle
struct in Listing 8.1 is immutable because all properties are declared using C# 6.0’s read-only, automatically implemented property capability. To create a read-only property without C# 6.0, programmers will need to declare a property with only a getter that accesses its data from a readonly
modified field (see Listing 8.3). C# 6.0 provides a noticeable code reduction when it comes to defining immutable types.
Note
Although nothing in the language requires it, a good guideline is for value types to be immutable: Once you have instantiated a value type, you should not be able to modify the same instance. In scenarios where modification is desirable, you should create a new instance. Listing 8.1 supplies a Move()
method that doesn’t modify the instance of Angle
, but instead returns an entirely new instance.
There are two good reasons for this guideline. First, value types should represent values. One does not think of adding two integers together as mutating either of them; rather, the two addends are immutable and a third value is produced as the result.
Second, because value types are copied by value, not by reference, it is very easy to get confused and incorrectly believe that a mutation to one value type variable can be observed to cause a mutation in another, as it would with a reference type.
Guidelines
DO create value types that are immutable.
In addition to properties and fields, structs may contain methods and constructors. However, user-defined default (parameterless) constructors were not allowed until C# 6.0. When no default constructor is provided, the C# compiler automatically generates a default constructor that initializes all fields to their default values. The default value is null for a field of reference type data, a zero value for a field of numeric type, false for a field of Boolean type, and so on.
To ensure that a local value type variable can be fully initialized by a constructor, every constructor in a struct must initialize all fields (and read-only, automatically implemented properties) within the struct. (In C# 6.0, initialization via a read-only, automatically implemented property is sufficient because the backing field is unknown and its initialization would not be possible.) Failure to initialize all data within the struct causes a compile-time error. To complicate matters slightly, C# disallows field initializers in a struct. Listing 8.2, for example, will not compile if the line _Degrees = 42
was uncommented.
struct Angle
{
// ...
// ERROR: Fields cannot be initialized at declaration time
// int _Degrees = 42;
// ...
}
If not explicitly instantiated via the new
operator’s call to the constructor, all data contained within the struct is implicitly initialized to that data’s default value. However, all data within a value type must be explicitly initialized to avoid a compiler error. This raises a question: When might a value type be implicitly initialized but not explicitly instantiated? This situation occurs when instantiating a reference type that contains an unassigned field of value type as well as when instantiating an array of value types without an array initializer.
To fulfill the initialization requirement on a struct, all explicitly declared fields must be initialized. Such initialization must be done directly. For example, in Listing 8.3, the constructor that initializes the property (if uncommented out) rather than the field produces a compile error.
struct Angle
{
// ERROR: The 'this' object cannot be used before
// all of its fields are assigned to
// public Angle(int degrees, int minutes, int seconds)
// {
// Degrees = degrees;
// Minutes = minutes;
// Seconds = seconds;
// }
public Angle(int degrees, int minutes, int seconds)
{
_Degrees = degrees;
_Minutes = minutes;
_Seconds = seconds;
}
public int Degrees { get { return _Degrees; } }
readonly private int _Degrees;
public int Minutes { get { return _Minutes; } }
readonly private int _Minutes;
public int Seconds { get { return _Seconds; } }
readonly private int _Seconds;
// ...
}
It is not legal to access this
until the compiler knows that all fields have been initialized; the use of Degrees
is implicitly this.Degrees
. To resolve this issue, you need to initialize the fields directly, as demonstrated in the constructor of Listing 8.3 that is not commented out.
Because of the struct’s field initialization requirement, the succinctness of C# 6.0’s read-only, automatically implemented property support, and the guideline to avoid accessing fields from outside of their wrapping property, you should favor read-only, automatically implemented properties over fields within structs starting with C# 6.0.
Guidelines
DO ensure that the default value of a struct is valid; it is always possible to obtain the default “all zero” value of a struct.
As described earlier, if no default constructor is provided (which is possible only starting with C# 6.0), all value types have an automatically defined default constructor that initializes the storage of a value type to its default state. Therefore, it is always legal to use the new
operator to create a value type instance. As an alternative syntax, you can use the default
operator to produce the default value for a struct. In Listing 8.4, we add a second constructor to the Angle
struct that uses the default
operator on int
as an argument to the previously declared three-argument constructor.
// Use keyword struct to declare a value type.
struct Angle
{
public Angle(int degrees, int minutes)
: this( degrees, minutes, default(int) )
{
}
// ...
}
The expressions default(int)
and new int()
both produce the same value. In contrast, that is not necessarily the case for custom-defined value types if the constructor is a C# 6.0 custom default constructor. In C# 6.0, a default constructor initializes its data to nondefault values. The result is that an invocation of the default constructor—which requires the new
operator—would not produce the same value that default(T)
produces. Like reference types, custom default constructors are invoked only explicitly via the new
operator. However, unlike reference types, whose default value is null, implicit initialization of value types results in a zeroed-out memory block equivalent to the result of the default
operator. Hence, default(T)
is not necessarily equivalent to new T()
when a value type has a default constructor. Furthermore, accessing the implicitly initialized value type is a valid operation; accessing the default value of a reference type, in contrast, would produce a NullReferenceException
. For this reason, you should take care to explicitly initialize value types with custom default constructors if the default(T)
value is not a valid state for the type.
Note
Default constructors on value types are invoked only by explicit uses of the new
operator.
End 6.0
All value types are implicitly sealed. In addition, all non-enum value types derive from System.ValueType
. As a consequence, the inheritance chain for structs is always from object
to System.ValueType
to the struct.
Value types can implement interfaces, too. Many of those built into the framework implement interfaces such as IComparable
and IFormattable
.
System.ValueType
brings with it the behavior of value types, but it does not include any additional members. The System.ValueType
customizations focus on overriding all of object
’s virtual members. The rules for overriding base class methods in a struct are almost the same as those for classes (see Chapter 9). However, one difference is that with value types, the default implementation for GetHashCode()
is to forward the call to the first non-null field within the struct. Also, Equals()
makes significant use of reflection. Therefore, if a value type is used frequently inside collections, especially dictionary-type collections that use hash codes, the value type should include overrides for both Equals()
and GetHashCode()
to ensure good performance. See Chapter 9 for more details.
Guidelines
DO overload the equality operators (Equals()
, ==
, and !=
) on value types, if equality is meaningful. (Also consider implementing the IEquatable<T>
interface.)
We know that variables of value type directly contain their data, whereas variables of reference type contain a reference to another storage location. But what happens when a value type is converted to one of its implemented interfaces or to its root base class, object
? The result of the conversion has to be a reference to a storage location that contains something that looks like an instance of a reference type, but the variable contains a value of value type. Such a conversion, which is known as boxing, has special behavior. Converting a variable of value type that directly refers its data to a reference type that refers to a location on the garbage-collected heap involves several steps.
1. Memory is allocated on the heap that will contain the value type’s data and the other overhead necessary to make the object look like every other instance of a managed object of reference type (namely, a SyncBlockIndex
and method table pointer).
2. The value of the value type is copied from its current storage location into the newly allocated location on the heap.
3. The result of the conversion is a reference to the new storage location on the heap.
The reverse operation is unboxing. The unboxing conversion checks whether the type of the boxed value is compatible with the type to which the value is being unboxed, and then results in a copy of the value stored in the heap location.
Boxing and unboxing are important to consider because boxing has some performance and behavioral implications. Besides learning how to recognize these conversions within C# code, a developer can count the box/unbox instructions in a particular snippet of code by looking through the CIL. Each operation has specific instructions, as shown in Table 8.1.
When boxing and unboxing occur infrequently, their implications for performance are irrelevant. However, boxing can occur in some unexpected situations, and frequent occurrences can have a significant impact on performance. Consider Listing 8.5 and Output 8.1. The ArrayList
type maintains a list of references to objects, so adding an integer or floating-point number to the list will box the value so that a reference can be obtained.
class DisplayFibonacci
{
static void Main()
{
int totalCount;
System.Collections.ArrayList list =
new System.Collections.ArrayList();
Console.Write("Enter a number between 2 and 1000:");
totalCount = int.Parse(Console.ReadLine());
// Execution-time error:
// list.Add(0); // Cast to double or 'D' suffix required.
// Whether cast or using 'D' suffix,
// CIL is identical.
list.Add((double)0);
list.Add((double)1);
for (int count = 2; count < totalCount; count++)
{
list.Add(
((double)list[count - 1] +
(double)list[count - 2]) );
}
foreach (double count in list)
{
Console.Write("{0}, ", count);
}
}
}
Enter a number between 2 and 1000:42
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269, 2178309, 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, 102334155, 165580141,
The code shown in Listing 8.5, when compiled, produces five box and three unbox instructions in the resultant CIL.
1. The first two box instructions occur in the initial calls to list.Add()
. The signature for the ArrayList
method is int Add(object value)
. As such, any value type passed to this method is boxed.
2. Next are two unbox instructions in the call to Add()
within the for
loop. The return from an ArrayList
’s index operator is always object
because that is what ArrayList
contains. To add the two values, you need to cast them back to double
s. This cast from a reference to an object to a value type is implemented as an unbox call.
3. Now you take the result of the addition and place it into the ArrayList
instance, which again results in a box operation. Note that the first two unbox instructions and this box instruction occur within a loop.
4. In the foreach
loop, you iterate through each item in ArrayList
and assign the items to count
. As you saw earlier, the items within ArrayList
are references to object
s, so assigning them to a double
is, in effect, unboxing each of them.
5. The signature for Console.WriteLine()
, which is called within the foreach
loop, is void Console.Write(string format, object arg)
. As a result, each call to it boxes the double
to object
.
Every boxing operation involves both an allocation and a copy; every unboxing operation involves a type check and a copy. Doing the equivalent work using the unboxed type would eliminate the allocation and type check. Obviously, you can easily improve this code’s performance by eliminating many of the boxing operations. Using an object
rather than double
in the last foreach
loop is one such improvement. Another would be to change the ArrayList
data type to a generic collection (see Chapter 11). The point being made here is that boxing can be rather subtle, so developers need to pay special attention and notice situations where it could potentially occur repeatedly and affect performance.
Another unfortunate boxing-related problem also occurs at runtime: If you wanted to change the initial two Add()
calls so that they did not use a cast (or a double literal), you would have to insert integers into the array list. Since int
s will implicitly be converted to double
s, this would appear to be an innocuous modification. However, the casts to double
from within the for
loop, and again in the assignment to count
in the foreach
loop, would fail. The problem is that immediately following the unbox operation is an attempt to perform a memory copy of the value of the boxed int
into a double
. You cannot do this without first casting to an int
, because the code will throw an InvalidCastException
at execution time. Listing 8.6 shows a similar error commented out and followed by the correct cast.
// ...
int number;
object thing;
double bigNumber;
number = 42;
thing = number;
// ERROR: InvalidCastException
// bigNumber = (double)thing;
bigNumber = (double)(int)thing;
// ...
Compare the two code snippets shown in Listing 8.9.
int connectionState;
// ...
switch (connectionState)
{
case 0:
// ...
break;
case 1:
// ...
break;
case 2:
// ...
break;
case 3:
// ...
break;
}
ConnectionState connectionState;
// ...
switch (connectionState)
{
case ConnectionState.Connected:
// ...
break;
case ConnectionState.Connecting:
// ...
break;
case ConnectionState.Disconnected:
// ...
break;
case ConnectionState.Disconnecting:
// ...
break;
}
Obviously, the difference in terms of readability is tremendous—in the second snippet, the cases are self-documenting. However, the performance at runtime is identical. To achieve this outcome, the second snippet uses enum values in each case.
An enum is a value type that the developer can declare. The key characteristic of an enum is that it declares at compile time a set of possible constant values that can be referred to by name, thereby making the code easier to read. The syntax for a typical enum declaration is show in Listing 8.10.
enum ConnectionState
{
Disconnected,
Connecting,
Connected,
Disconnecting
}
Note
An enum can be used as a more readable replacement for Boolean values as well. For example, a method call such as SetState(true)
is less readable than SetState(DeviceState.On)
.
You use an enum value by prefixing it with the enum name. To use the Connected
value, for example, you would use the syntax ConnectionState.Connected
. Do not make the enum type name a part of the value’s name so as to avoid the redundancy of something such as ConnectionState.ConnectionStateConnected
. By convention, the enum name itself should be singular (unless the enums are bit flags, as discussed shortly). That is, the nomenclature should be ConnectionState
, not ConnectionStates
.
Enum values are actually implemented as nothing more than integer constants. By default, the first enum value is given the value 0
, and each subsequent entry increases by 1. However, you can assign explicit values to enums, as shown in Listing 8.11.
enum ConnectionState : short
{
Disconnected,
Connecting = 10,
Connected,
Joined = Connected,
Disconnecting
}
In this code, Disconnected
has a default value of 0
and Connecting
has been explicitly assigned 10
; consequently, Connected
will be assigned 11
. Joined
is assigned 11
, the value assigned to Connected
. (In this case, you do not need to prefix Connected
with the enum name, since it appears within its scope.) Disconnecting
is 12
.
An enum always has an underlying type, which may be any integral type other than char
. In fact, the enum type’s performance is identical to that of the underlying type. By default, the underlying value type is int
, but you can specify a different type using inheritance type syntax. Instead of int
, for example, Listing 8.11 uses a short
. For consistency, the syntax for enums emulates the syntax of inheritance, but this doesn’t actually make an inheritance relationship. The base class for all enums is System.Enum
, which in turn is derived from System.ValueType
. Furthermore, these classes are sealed; you can’t derive from an existing enum type to add additional members.
Guidelines
CONSIDER using the default 32-bit integer type as the underlying type of an enum. Use a smaller type only if you must do so for interoperability or performance reasons; use a larger type only if you are creating a flags enum (see the discussion later in this chapter) with more than 32 flags.
An enum is really nothing more than a set of names thinly layered on top of the underlying type; there is no mechanism that restricts the value of a variable of enumerated type to just the values named in the declaration. For example, because it is possible to cast the integer 42
to short
, it is also possible to cast the integer 42
to the ConnectionState
type, even though there is no corresponding ConnectionState
enum value. If the value can be converted to the underlying type, the conversion to the enum type will also be successful.
The advantage of this odd feature is that enums can have new values added in later API releases, without breaking earlier versions. Additionally, the enum values provide names for the known values while still allowing unknown values to be assigned at runtime. The burden is that developers must code defensively for the possibility of unnamed values. It would be unwise, for example, to replace case ConnectionState.Disconnecting
with default
and expect that the only possible value for the default
case was ConnectionState.Disconnecting
. Instead, you should handle the Disconnecting
case explicitly and the default
case should report an error or behave innocuously. As indicated earlier, however, conversion between the enum and the underlying type, and vice versa, requires an explicit cast; it is not an implicit conversion. For example, code cannot call ReportState(10)
if the method’s signature is void ReportState(ConnectionState state)
. The only exception occurs when passing 0
, because there is an implicit conversion from 0
to any enum.
Although you can add more values to an enum in a later version of your code, you should do so with care. Inserting an enum value in the middle of an enum will bump the values of all later enums (adding Flooded
or Locked
before Connected
will change the Connected
value, for example). This will affect the versions of all code that is recompiled against the new version. However, any code compiled against the old version will continue to use the old values, making the intended values entirely different. Besides inserting an enum value at the end of the list, one way to avoid changing enum values is to assign values explicitly.
Guidelines
CONSIDER adding new members to existing enums, but keep in mind the compatibility risk.
AVOID creating enums that represent an “incomplete” set of values, such as product version numbers.
AVOID creating “reserved for future use” values in an enum.
AVOID enums that contain a single value.
DO provide a value of 0
(none) for simple enums, knowing that 0
will be the default value when no explicit initialization is provided.
Enums are slightly different from other value types because they derive from System.Enum
before deriving from System.ValueType
.
C# also does not support a direct cast between arrays of two different enums. However, the CLR does, provided that both enums share the same underlying type. To work around this restriction of C#, the trick is to cast first to System.Array
, as shown at the end of Listing 8.12.
enum ConnectionState1
{
Disconnected,
Connecting,
Connected,
Disconnecting
}
enum ConnectionState2
{
Disconnected,
Connecting,
Connected,
Disconnecting
}
class Program
{
static void Main()
{
ConnectionState1[] states =
(ConnectionState1[])(Array)new ConnectionState2[42];
}
}
This example exploits the fact that the CLR’s notion of assignment compatibility is more lenient than C#’s concept. (The same trick is possible for other illegal conversions, such as int[]
to uint[]
.) However, use this approach cautiously because there is no C# specification requiring that this behavior work across different CLR implementations.
One of the conveniences associated with enums is that the ToString()
method, which is called by methods such as System.Console.WriteLine()
, writes out the enum value identifier:
System.Diagnostics.Trace.WriteLine(
$"The connection is currently { ConnectionState.Disconnecting }");
The preceding code will write the text in Output 8.3 to the trace buffer.
The connection is currently Disconnecting.
Conversion from a string to an enum is a little more difficult to achieve, because it involves a static method on the System.Enum
base class. Listing 8.13 provides an example of how to do it without generics (see Chapter 11), and Output 8.4 shows the results.
ThreadPriorityLevel priority = (ThreadPriorityLevel)Enum.Parse(
typeof(ThreadPriorityLevel), "Idle");
Console.WriteLine(priority);
Idle
In this code, the first parameter to Enum.Parse()
is the type, which you specify using the keyword typeof()
. This example depicts a compile-time way of identifying the type, like a literal for the type value (see Chapter 17).
Until .NET Framework 4, there was no TryParse()
method, so code written to target prior versions needs to include appropriate exception handling if there is a chance the string will not correspond to an enum value identifier. .NET Framework 4’s TryParse<T>()
method uses generics, but the type parameters can be inferred, resulting in the to-enum conversion behavior shown in Listing 8.14.
System.Diagnostics.ThreadPriorityLevel priority;
if(Enum.TryParse("Idle", out priority))
{
Console.WriteLine(priority);
}
This technique eliminates the need to use exception handling if the string might not convert successfully. Instead, code can check the Boolean result returned from the call to TryParse<T>()
.
Regardless of whether the code uses the “Parse” or “TryParse” approach, the key caution about converting from a string to an enum is that such a cast is not localizable. Therefore, developers should use this type of cast only for messages that are not exposed to users (assuming localization is a requirement).
AVOID direct enum/string conversions where the string must be localized into the user’s language.
Many times, developers not only want enum values to be unique, but also want to be able to represent a combination of values. For example, consider System.IO.FileAttributes
. This enum, shown in Listing 8.15, indicates various attributes on a file: read-only, hidden, archive, and so on. Unlike with the ConnectionState
attribute, where each enum value was mutually exclusive, the FileAttributes
enum values can and are intended for combination: A file can be both read-only and hidden. To support this behavior, each enum value is a unique bit.
[Flags] public enum FileAttributes
{
ReadOnly = 1<<0, // 000000000000000001
Hidden = 1<<1, // 000000000000000010
System = 1<<2, // 000000000000000100
Directory = 1<<4, // 000000000000010000
Archive = 1<<5, // 000000000000100000
Device = 1<<6, // 000000000001000000
Normal = 1<<7, // 000000000010000000
Temporary = 1<<8, // 000000000100000000
SparseFile = 1<<9, // 000000001000000000
ReparsePoint = 1<<10, // 000000010000000000
Compressed = 1<<11, // 000000100000000000
Offline = 1<<12, // 000001000000000000
NotContentIndexed = 1<<13, // 000010000000000000
Encrypted = 1<<14, // 000100000000000000
IntegrityStream = 1<<15, // 001000000000000000
NoScrubData = 1<<17, // 100000000000000000
}
Note
Note that the name of a bit flags enum is usually pluralized, indicating that a value of the type represents a set of flags.
To join enum values, you use a bitwise OR operator. To test for the existence of a particular bit you use the bitwise AND operator. Both cases are illustrated in Listing 8.16.
using System;
using System.IO;
public class Program
{
public static void Main()
{
// ...
string fileName = @"enumtest.txt";
System.IO.FileInfo file =
new System.IO.FileInfo(fileName);
file.Attributes = FileAttributes.Hidden |
FileAttributes.ReadOnly;
Console.WriteLine("{0} | {1} = {2}",
FileAttributes.Hidden, FileAttributes.ReadOnly,
(int)file.Attributes);
if ( (file.Attributes & FileAttributes.Hidden) !=
FileAttributes.Hidden)
{
throw new Exception("File is not hidden.");
}
if (( file.Attributes & FileAttributes.ReadOnly) !=
FileAttributes.ReadOnly)
{
throw new Exception("File is not read-only.");
}
// ...
}
The results of Listing 8.16 appear in Output 8.5.
Hidden | ReadOnly = 3
Using the bitwise OR operator allows you to set the file to both read-only and hidden. In addition, you can check for specific settings using the bitwise AND operator.
Each value within the enum does not need to correspond to only one flag. It is perfectly reasonable to define additional flags that correspond to frequent combinations of values. Listing 8.17 shows an example.
[Flags] enum DistributedChannel
{
None = 0,
Transacted = 1,
Queued = 2,
Encrypted = 4,
Persisted = 16,
FaultTolerant =
Transacted | Queued | Persisted
}
It is a good practice to have a zero None
member in a flags enum because the initial default value of a field of enum type or an element of an array of enum type is 0
. Avoid enum values corresponding to items such as Maximum
as the last enum, because Maximum
could be interpreted as a valid enum value. To check whether a value is included within an enum, use the System.Enum.IsDefined()
method.
Guidelines
DO use the FlagsAttribute
to mark enums that contain flags.
DO provide a None
value equal to 0
for all flag enums.
AVOID creating flag enums where the zero value has a meaning other than “no flags are set.”
CONSIDER providing special values for commonly used combinations of flags.
DO NOT include “sentinel” values (such as a value called Maximum
); such values can be confusing to the user.
DO use powers of 2 to ensure that all flag combinations are represented uniquely.
This chapter began with a discussion of how to define custom value types. Because it is easy to write confusing or buggy code when mutating value types, and because value types are typically used to model immutable values, it is a good idea to make value types immutable. We also described how value types are “boxed” when they must be treated polymorphically as reference types.
The idiosyncrasies introduced by boxing are subtle, and the vast majority of them lead to problematic issues at execution time rather than at compile time. Although it is important to know about these quirks so as to try to avoid them, in many ways paying too much attention to the potential pitfalls overshadows the usefulness and performance advantages of value types. Programmers should not be overly concerned about using value types. Value types permeate virtually every chapter of this book, yet the idiosyncrasies associated with them come into play infrequently. We have staged the code surrounding each issue to demonstrate the concern, but in reality these types of patterns rarely occur. The key to avoiding most of them is to follow the guideline of not creating mutable value types and following this constraint explains why you don’t encounter them within the built-in value types.
Perhaps the only issue to occur with some frequency is repetitive boxing operations within loops. However, generics greatly reduce boxing, and even without them, performance is rarely affected enough to warrant their avoidance until a particular algorithm with boxing is identified as a bottleneck.
Furthermore, custom-built structs are relatively rare. They obviously play an important role within C# development, but the number of custom-built structs declared by typical developers is usually tiny compared to the number of custom-built classes. Heavy use of custom-built structs is most common in code targeted at interoperating with unmanaged code.
Guidelines
DO NOT define a struct unless it logically represents a single value, consumes 16 bytes or less of storage, is immutable, and is infrequently boxed.
This chapter also introduced enums. Enumerated types are a standard construct available in many programming languages. They help improve both API usability and code readability.
The next chapter presents more guidelines for creating well-formed types—both value types and reference types. It begins by looking at overriding the virtual members of objects and defining operator-overloading methods. These two topics apply to both structs and classes, but they are somewhat more important when completing a struct definition and making it well formed.