- 1 A First C# Program
- 2 Namespaces
- 3 Alternative Forms of the Main() Function
- 4 Making a Statement
- 5 Opening a Text File for Reading and Writing
- 6 Formatting Output
- 7 The string Type
- 8 Local Objects
- 9 Value and Reference Types
- 10 The C# Array
- 11 The new Expression
- 12 Garbage Collection
- 13 Dynamic Arrays: The ArrayList Collection Class
- 14 The Unified Type System
- 15 Jagged Arrays
- 16 The Hashtable Container
- 17 Exception Handling
- 18 A Basic Language Handbook for C#
1.14 The Unified Type System
When we define an object, we must specify its type. The type determines the kind of values the object can hold and the permissible range of those values. For example, byte is an unsigned integral type with a size of 8 bits. The definition
declares that b can hold integral values, but that those values must be within the range of 0 to 255. If we attempt to assign b a floating-point value:
b = 3.14159; // compile-time error
a string value:
b = "no way"; // compile-time error
or an integer value outside its range:
b = 1024; // compile-time error
each of those assignments is flagged as a type error by the compiler. This is true of the C# array type as well. So why is an ArrayList container able to hold objects of any type?
The reason is the unified type system. C# predefines a reference type named object. Every reference and value typeboth those predefined by the language and those introduced by programmers like usis a kind of object. This means that any type we work with can be assigned to an instance of type object. For example, given
each of the following assignments is legal:
o = 10; o = "hello, object"; o = 3.14159; o = new int[ 24 ]; o = new WordCount(); o = false;
We can assign any type to an ArrayList container because its elements are declared to be of type object.
object provides a fistful of public member functions. The most frequently used method is ToString(), which returns a string representation of the actual typefor example,
Console.WriteLine( o.ToString() );
1.14.1 Shadow Boxing
Although it may not be immediately apparent, there is something very strange about assigning an object type with an object of type int. The reason is that object is a reference type, while int is a value type.
In case you don't remember, a reference type consists of two parts: the named handle that we manipulate in our program, and an unnamed object allocated on the managed heap by the new expression. When we initialize or assign one reference type with another, the two handles now refer to the same unnamed object on the heap. This is the shallow copy that was introduced earlier.
A value type is not represented as a handle/object pair. Rather the declared object directly contains its data. A value type is not allocated on the managed heap. It is neither reference-counted nor garbage-collected. Rather its lifetime is equivalent to the extent of its containing environment. A local object's lifetime is the length of time that the function in which it is defined is executing. A class member's lifetime is equal to the lifetime of the class object to which it belongs.
The strangeness of assigning a value type to an object instance should seem a bit clearer now. The object instance is a reference type. It represents a handle/object pair. A value type just holds its value and is not stored on the heap. How can the handle of the object instance refer to a value type?
Through an implicit conversion process called boxing, the compiler allocates a heap address to assign to the object instance. When we assign a literal value or an object of a value type to an object instance, the following steps take place: (1) an object box is allocated on the heap to hold the value, (2) the value is copied into the box, and (3) the object instance is assigned the heap address of the box.
1.14.2 Unboxing Leaves Us Downcast
There is not much we can do with an object except invoke one of its public member functions. We cannot access any of the methods or properties of the original type. For example, when we assign a string object to an object:
string s = "cat"; object o = s; // error: string property Length is not available // through the object instance ... if ( o.Length != 3 )
all knowledge of its original type is unavailable to the compiler. If we wish to make use of the Length property, we must first return the object back to a string. However, an object is not automatically converted to another type:
// error: no implicit conversion of an object type // to any other type ... string str = o;
A conversion is carried out automatically only if it can be guaranteed to be safe. For the compiler to determine that, it must know both the source and the target types. With an object instance, all type information is absentat least for the compiler. (The type and environment information, however, is available both to the runtime environment and to us, the programmers, during program execution. We look at accessing that information in Chapter 8.)
For any conversion for which the compiler cannot guarantee safety, the user is required to do an explicit type castfor example,
string str = ( string ) o;
The explicit cast directs the compiler to perform the type conversion even though a compile-time analysis suggests that it is potentially unsafe. What if the programmer is wrong? Does this mean we have a hard bug to dig out?
The full type information is available to the runtime environment, and if it turns out that o really does not represent a string object, the type mismatch is recognized and a runtime exception is thrown. So if the programmer is incorrect with an explicit cast, we have a bug, but because of the automatic runtime check, not one that is difficult to track down.
Two operators can help us determine the correctness of our cast: is and as. We use the is operator to ask if a reference type is actually a particular typefor example,
string str; if ( o is string ) str = ( string ) o;
The is operator is evaluated at runtime and returns true if the actual object is of the particular type. This does not relieve us of the need for the explicit cast, however. The compiler does not evaluate our program's logic.
Alternatively, we can use the as operator to perform the cast at runtime if the actual object is of the particular type that interests usfor example,
string str = o as string;
If o is not of the appropriate type, the conversion is not applied and str is set to null. To discover whether the downcast has been carried out, we test the target of the conversion:
if ( str != null ) // OK: o does reference a string ...
In converting an object instance to a particular reference type, the only work required is setting the handle to the object's heap address. Converting an object instance to a particular value type requires a bit more work because an object of a value type directly contains its data.
This additional work in converting a reference type back to a value type is called unboxing. The data copied into the previously generated box is copied back into the object of the target value type. The reference count of the associated box on the managed heap is decremented by 1.