Structs (Value Types) in C#

1. A Point Struct

In a graphics system, a value class could encapsulate a point. Here’s how you’d declare it:

using System; struct Point {

public Point(int x, int y)

{

this.x = x;

this.y = y;

}

public override string ToString()

{

return(String.Format(“({0}, {1})”, x, y));

}

public int x;

public int y;

}

class Test {

public static void Main()

{

Point start = new Point(5, 5);

Console.WriteLine(“Start: {0}”, start);

}

}

The x and y components of the Point can be accessed. In the Main() function, a Point is created using the new keyword. For value types, new creates an object on the stack and then calls the appropriate constructor.

The call to Console.WriteLine() is a bit mysterious. If Point is allocated on the stack, how does that call work?

2. Boxing and Unboxing

In C# and the .NET runtime world, a little bit of magic takes place to make value types look like reference types, and that magic is called boxing. As magic goes, it’s pretty simple. In the call to Console.WriteLine(), the compiler is looking for a way to convert start to an object because the type of the second parameter to WriteLine() is object. For a reference type (in other words, a class), this is easy because object is the base class of all classes. The compiler merely passes an object reference that refers to the class instance.

There’s no reference-based instance for a value class, however, so the C# compiler allo­cates a reference-type “box” for the Point, marks the box as containing a Point, and copies the value of the Point into the box. It’s now a reference type, and you can treat it like an object.

This reference is then passed to the WriteLine() function, which calls the ToString() function on the boxed Point, which gets dispatched to the ToString() function, and the code writes the following:

Start: (5, 5)

Boxing happens automatically whenever a value type is used in a location that requires (or could use) an object.

The boxed value is retrieved into a value type by unboxing it:

int v = 123;

object o = v;        // box the int 123

int v2 = (int) o; // unbox it back to an integer

Assigning the object o, the value 123 boxes the integer, which is then extracted back on the next line. That cast to int is required because the object o could be any type of object and because the cast could fail.

Figure 9-1 shows how this would be represented. Assigning the int to the object variable results in the box being allocated on the heap and the value being copied into the box. The box is then labeled with the type it contains so the runtime knows the type of the boxed object.

During the unboxing conversion, the type must match exactly; a boxed value type can’t be unboxed to a compatible type:

object o = 15;

short s = (short) o;        // fails, o doesn’t contain a short

short t = (short)(int) o; // this works

It’s fairly rare to write code that does boxing explicitly. It’s much more common to write code where the boxing happens because the value type is passed to a function that expects a parameter of type object, like the following code:

int value = 15;

DateTime date = new DateTime();

Console.WriteLine(“Value, Date: {0} {1}”, value, date);

In this case, both value and date will be boxed when WriteLine() is called.

3. Structs and Constructors

Structs and constructors behave a bit differently than classes. In classes, an instance must be created by calling new before the object is used; if new isn’t called, there will be no created instance, and the reference will be null.

There’s no reference associated with a struct, however. If new isn’t called on the struct, an instance that has all of its fields zeroed is created. In some cases, a user can then use the instance without further initialization. For example:

using System; struct Point {

int x; int y;

Point(int x, int y)

{

this.x = x;

this.y = y;

}

public override string ToString()

{

return(String.Format(“({0}, {1})”, x, y));

}

}

class Test {

public static void Main()

{

Point[] points = new Point[5];

Console.WriteLine(“[2] = {0}”, points[2]);

}

}

Although this struct has no default constructor, it’s still easy to get an instance that didn’t come through the right constructor.

It’s therefore important to make sure that the all-zeroed state is a valid initial state for all value types.

A default (parameterless) constructor for a struct could set different values than the all- zeroed state, which would be unexpected behavior. The .NET runtime therefore prohibits default constructors for structs.

4. Design Guidelines

You should use structs only for types that are really just pieces of data—for types that could be used in a similar way to the built-in types. A type, for example, is like the built-in type decimal, which is implemented as a value type.

Even if more complex types can be implemented as value types, they probably shouldn’t be, since the value type semantics will probably not be expected by the user. The user will expect that a variable of the type could be null, which isn’t possible with value types.

5. Immutable Classes

Value types nicely result in value semantics, which is great for types that “feel like data.” But what if it’s a type that needs to be a class type for implementation reasons but is still a data type, such as the string type?

To get a class to behave as if it were a value type, you need to write the class as an immu­table type. Basically, an immutable type is one designed so that it’s not possible to tell it has reference semantics for assignment.

Consider the following example, with string written as a normal class:

string s = “Hello There”;

string s2 = s;

s.Replace(“Hello”, “Goodbye”);

Because string is a reference type, both s and s2 will end up referring to the same string instance. When that instance is modified through s, the views through both variables will be changed.

The way to get around this problem is simply to prohibit any member functions that change the value of the class instance. In the case of string, member functions that look like they’d change the value of the string instead return a new string with the modified value.

A class where there are no member functions that can change—or mutate—the value of an instance is called an immutable class. The revised example looks like this:

string s = “Hello There”;

string s2 = s;

s = s.Replace(“Hello”, “Goodbye”);

After the third line has been executed, s2 still points to the original instance of the string, and s now points to a new instance containing the modified value.

6. A Simple Example

The following code defines the interface IScalable and the class TextObject, which implements the interface, meaning that it contains implementations of all the functions defined in the interface:

public class DiagramObject {

public DiagramObject() {}

}

interface IScalable {

void ScaleX(float factor);

void ScaleY(float factor);

}

// A diagram object that also implements IScalable

public class TextObject: DiagramObject, IScalable

{

public TextObject(string text)

{

this.text = text;

}

// implementing IScalable.ScaleX()

public void ScaleX(float factor)

{

// scale the object here.

}

// implementing IScalable.ScaleY()

public void ScaleY(float factor)

{

// scale the object here.

}

private string text;

}

class Test {

public static void Main()

{

TextObject text = new TextObject(“Hello”);

IScalable scalable = (IScalable) text;

scalable.ScaleX(0.5F);

scalable.ScaleY(0.5F);

}

}

This code implements a system for drawing diagrams. All the objects derive from DiagramObject, so they can implement common virtual functions (not shown in this example). Some of the objects can be scaled, and this is expressed by the presence of an implementation of the IScalable interface.

Listing the interface name with the base class name for TextObject indicates that TextObject implements the interface. This means TextObject must have functions that match every function in the interface. Interface members have no access modifiers, and the class members that implement the interface members must be publicly accessible.

When an object implements an interface, a reference to the interface can be obtained by casting to the interface. This can then be used to call the functions on the interface.

This example could have been done with abstract methods, by moving the ScaleX() and ScaleY() methods to DiagramObject and making them virtual. The “Design Guidelines” section later in this chapter discusses when to use an abstract method and when to use an interface.

Source: Gunnerson Eric, Wienholt Nick (2005), A Programmer’s Introduction to C# 2.0, Apress; 3rd edition.

Leave a Reply

Your email address will not be published. Required fields are marked *