Base Classes and Inheritance in C#

1. The Engineer Class

The following class implements Engineer and methods to handle billing for Engineer:

using System; class Engineer {

// constructor

public Engineer(string name, float billingRate)

{

this.name = name;

this.billingRate = billingRate;

}

// figure out the charge based on engineer’s rate

public float CalculateCharge(float hours)

{

return(hours * billingRate);

}

// return the name of this type public string TypeName()

{

return(“Engineer”);

}

private string name;

protected float billingRate;

}

class Test {

public static void Main()

{

Engineer engineer = new Engineer(“Hank”, 21.20F);

Console.WriteLine(“Name is: {0}”, engineer.TypeName());

}

}

Engineer will serve as a base class for this scenario. It contains the private field name and the protected field billingRate. The protected modifier grants the same access as private; however, classes that are derived from this class also have access to the field. Protected is therefore used to give classes that derive from this class access to a field.

Protected access allows other classes to depend upon the internal implementation of the class and therefore should be granted only when necessary. In the example, the billingRate member can’t be renamed, since derived classes may access it. It’s often a better design choice to use a protected property.

The Engineer class also has a member function that can be used to calculate the charge based on the number of hours of work done.

2. Simple Inheritance

A CivilEngineer is a type of engineer and therefore can be derived from the Engineer class:

using System; class Engineer {

public Engineer(string name, float billingRate)

{

this.name = name;

this.billingRate = billingRate;

}

public float CalculateCharge(float hours)

{

return(hours * billingRate);

}

public string TypeName()

{

return(“Engineer”);

}

private string name; protected float billingRate;

}

class CivilEngineer: Engineer {

public CivilEngineer(string name, float billingRate) : base(name, billingRate)

{

}

// new function, because it’s different than the

// base version

public new float CalculateCharge(float hours)

{

if (hours < 1.0F)

hours = 1.0F;        // minimum charge.

return(hours * billingRate);

}

// new function, because it’s different than the

// base version

public new string TypeName()

{

return(“Civil Engineer”);

}

}

class Test {

public static void Main()

{

Engineer e = new Engineer(“George”, 15.50F);

CivilEngineer c = new CivilEngineer(“Sir John”, 40F);

Console.WriteLine(“{0} charge = {1}”, e.TypeName(), e.CalculateCharge(2F));

Console.WriteLine(“{0} charge = {1}”, c.TypeName(),

c.CalculateCharge(0.75F));

}

}

Because the CivilEngineer class derives from Engineer, it inherits all the data members of the class (though the name member can’t be accessed because it’s private), and it also inherits the CalculateCharge() member function.

Constructors can’t be inherited, so a separate one is written for CivilEngineer. The constructor doesn’t have anything special to do, so it calls the constructor for Engineer, using the base syntax. If you omitted the call to the base class constructor, the compiler would call the base class constructor with no parameters.

CivilEngineer has a different way to calculate charges; the minimum charge is for one hour of time, so there’s a new version of CalculateCharge().

The example, when run, yields the following output:

Engineer Charge = 31

Civil Engineer Charge = 40

3. Arrays of Engineers

This works fine in the early years, when there are only a few employees. As the company grows, it’s easier to deal with an array of engineers.

Because CivilEngineer is derived from Engineer, an array of type Engineer can hold either type. This example has a different Main() function, putting the engineers into an array:

using System; class Engineer {

public Engineer(string name, float billingRate)

{

this.name = name;

this.billingRate = billingRate;

}

public float CalculateCharge(float hours)

{

return(hours * billingRate);

}

public string TypeName()

{

return(“Engineer”);

}

private string name; protected float billingRate;

}

class CivilEngineer: Engineer {

public CivilEngineer(string name, float billingRate) : base(name, billingRate)

{

}

public new float CalculateCharge(float hours)

{

if (hours < 1.0F)

hours = 1.0F;        // minimum charge.

return(hours * billingRate);

}

public new string TypeName()

{

return(“Civil Engineer”);

}

}

class Test {

public static void Main()

{

// create an array of engineers

Engineer[] earray = new Engineer[2];

earray[0] = new Engineer(“George”, 15.50F);

earray[1] = new CivilEngineer(“Sir John”, 40F);

Console.WriteLine(“{0} charge = {1}”,

earray[0].TypeName(),

earray[0].CalculateCharge(2F));

Console.WriteLine(“{0} charge = {1}”,

earray[1].TypeName(),

earray[1].CalculateCharge(0.75F));

}

}

This version yields the following output:

Engineer Charge = 31

Engineer Charge = 30

That’s not right.

Because CivilEngineer is derived from Engineer, an instance of CivilEngineer can be used wherever an instance of Engineer is required.

When the engineers were placed into the array, the fact that the second engineer was really a CivilEngineer rather than an Engineer was lost. Because the array is an array of Engineer, when CalculateCharge() is called, the version from Engineer is called.

What’s needed is a way to correctly identify the type of an engineer. You can do this by having a field in the Engineer class that denotes what type it is. Rewriting the classes with an enum field to denote the type of the engineer gives the following example:

using System;

enum EngineerTypeEnum {

Engineer,

CivilEngineer

}

class Engineer

{

public Engineer(string name, float billingRate)

{

this.name = name;

this.billingRate = billingRate;

type = EngineerTypeEnum.Engineer;

}

public float CalculateCharge(float hours)

{

if (type == EngineerTypeEnum.CivilEngineer)

{

CivilEngineer c = (CivilEngineer) this; return(c.CalculateCharge(hours));

}

else if (type == EngineerTypeEnum.Engineer) return(hours * billingRate); return(0F);

}

public string TypeName()

{

if (type == EngineerTypeEnum.CivilEngineer)

{

CivilEngineer c = (CivilEngineer) this; return(c.TypeName());

}

else if (type == EngineerTypeEnum.Engineer) return(“Engineer”); return(“No Type Matched”);

}

private string name; protected float billingRate;

protected EngineerTypeEnum type;

}

class CivilEngineer: Engineer {

public CivilEngineer(string name, float billingRate) : base(name, billingRate)

{

type = EngineerTypeEnum.CivilEngineer;

}

public new float CalculateCharge(float hours)

{

if (hours < 1.0F)

hours = 1.0F;        // minimum charge.

return(hours * billingRate);

}

public new string TypeName()

{

return(“Civil Engineer”);

}

}

class Test {

public static void Main()

{

Engineer[] earray = new Engineer[2];

earray[0] = new Engineer(“George”, 15.50F);

earray[1] = new CivilEngineer(“Sir John”, 40F);

Console.WriteLine(“{0} charge = {1}”,

earray[0].TypeName(),

earray[0].CalculateCharge(2F));

Console.WriteLine(“{0} charge = {1}”,

earray[1].TypeName(),

earray[1].CalculateCharge(0.75F));

}

}

By looking at the type field, the functions in Engineer can determine the real type of the object and call the appropriate function.

The output of the code is as expected:

Engineer Charge = 31

Civil Engineer Charge = 40

Unfortunately, the base class has now become much more complicated; for every function that cares about the type of a class, there’s code to check all the possible types and call the correct function. That’s a lot of extra code, and it’d be untenable if there were 50 kinds of engineers.

Worse is that the base class needs to know the names of all the derived classes for it to work. If the owner of the code needs to add support for a new engineer, the base class must be modified. If a user who doesn’t have access to the base class needs to add a new type of engineer, it won’t work at all.

4. Virtual Functions

To make this work cleanly, object-oriented languages allow a function to be specified as virtual. Virtual means that when a call to a member function is made, the compiler should look at the real type of the object (not just the type of the reference) and call the appropriate function based on that type.

With that in mind, you can modify the example as follows:

using System; class Engineer {

public Engineer(string name, float billingRate)

{

this.name = name;

this.billingRate = billingRate;

}

// function now virtual

virtual public float CalculateCharge(float hours)

{

return(hours * billingRate);

}

// function now virtual
virtual public string TypeName()

{

return(“Engineer”);

}

private string name;

protected float billingRate;

}

class CivilEngineer: Engineer

{

public CivilEngineer(string name, float billingRate) : base(name, billingRate)

{

}

// overrides function in Engineer override

public float CalculateCharge(float hours)

{

if (hours < 1.0F)

hours = 1.0F;        // minimum charge.

return(hours * billingRate);

}

// overrides function in Engineer

override public string TypeName()

{

return(“Civil Engineer”);

}

}

class Test {

public static void Main()

{

Engineer[] earray = new Engineer[2];

earray[0] = new Engineer(“George”, 15.50F);

earray[1] = new CivilEngineer(“Sir John”, 40F);

Console.WriteLine(“{0} charge = {1}”,

earray[0].TypeName(),

earray[0].CalculateCharge(2F));

Console.WriteLine(“{0} charge = {1}”,

earray[1].TypeName(),

earray[1].CalculateCharge(0.75F));

}

}

The Ca lculateCharge() and TypeName() junctions are now declared with the virtual keyword in the base class, and that’s all the base class has to know. It needs no knowledge of the derived types, other than to know that each derived class can override CalculateCharge() and TypeName(), if desired. In the derived class, the functions are declared with the override keyword, which means they’re the same function that was declared in the base class. If the override keyword is missing, the compiler will assume that the function is unrelated to the base class’s function, and virtual dispatching won’t function.[1]

Running this example leads to the expected output:

Engineer Charge = 31

Civil Engineer Charge = 40

When the compiler encounters a call to TypeName() or CalculateCharge(), it goes to the definition of the function and notes that it’s a virtual function. Instead of generating code to call the function directly, it writes a bit of dispatch code that at runtime will look at the real type of the object and call the function associated with the real type, rather than just the type of the reference. This allows the correct function to be called even if the class wasn’t implemented when the caller was compiled.

For example, if some payroll processing code stored an array of Engineer, a new class derived from Engineer could be added to the system without having to modify or recompile the payroll code.

The virtual dispatch has a small amount of overhead, so you shouldn’t use it unless needed. A JIT could, however, notice that there were no derived classes from the class on which the function call was made and convert the virtual dispatch to a straight call.

5. Abstract Classes

The approach used so far has one small problem. A new class doesn’t have to implement the TypeName() function, since it can inherit the implementation from Engineer. This makes it easy for a new class of engineer to have the wrong name associated with it.

If the ChemicalEngineer class is added, the example looks like this:

using System; class Engineer {

public Engineer(string name, float billingRate)

{

this.name = name;

this.billingRate = billingRate;

}

virtual public float CalculateCharge(float hours)

{

return(hours * billingRate);

}

virtual public string TypeName()

{

return(“Engineer”);

}

private string name;

protected float billingRate;

}

class ChemicalEngineer: Engineer {

public ChemicalEngineer(string name, float billingRate) : base(name, billingRate)

{

}

// overrides mistakenly omitted

}

class Test {

public static void Main()

{

Engineer[] earray = new Engineer[2];

earray[0] = new Engineer(“George”, 15.50F);

earray[1] = new ChemicalEngineer(“Dr. Curie”, 45.50F);

Console.WriteLine(“{0} charge = {1}”,

earray[0].TypeName(),

earray[0].CalculateCharge(2F));

Console.WriteLine(“{0} charge = {1}”,

earray[1].TypeName(),

earray[1].CalculateCharge(0.75F));

}

}

The ChemicalEngineer class will inherit the CalculateCharge() function from Engineer, which might be correct, but it will also inherit TypeName(), which is definitely wrong. What’s needed is a way to force ChemicalEngineer to implement TypeName().

You can do this by changing Engineer from a normal class to an abstract class. In this abstract class, the TypeName() member function is marked as an abstract function, which means all classes that derive from Engineer will be required to implement the TypeName() function.

An abstract class defines a contract that derived classes are expected to follow.[2] Because an abstract class is missing “required” functionality, it can’t be instantiated, which for the example means that instances of the Engineer class can’t be created. So that there are still two distinct types of engineers, the ChemicalEngineer class has been added.

Abstract classes behave like normal classes except for one or more member functions that are marked as abstract. For example:

using System; abstract class Engineer {

public Engineer(string name, float billingRate)

{

this.name = name;

this.billingRate = billingRate;

}

virtual public float CalculateCharge(float hours)

{

return(hours * billingRate);

}

abstract public string TypeName();

private string name;

protected float billingRate;

}

class CivilEngineer: Engineer

{

public CivilEngineer(string name, float billingRate) : base(name, billingRate)

{

}

override public float CalculateCharge(float hours)

{

if (hours < 1.0F)

hours = 1.0F;        // minimum charge.

return(hours * billingRate);

}

// This override is required, or an error is generated.

override public string TypeName()

{

return(“Civil Engineer”);

}

}

class ChemicalEngineer: Engineer {

public ChemicalEngineer(string name, float billingRate) : base(name, billingRate)

{

}

override public string TypeName()

{

return(“Chemical Engineer”);

}

}

class Test {

public static void Main()

{

Engineer[] earray = new Engineer[2];

earray[0] = new CivilEngineer(“Sir John”, 40.0F);

earray[1] = new ChemicalEngineer(“Dr. Curie”, 45.0F);

Console.WriteLine(“{0} charge = {1}”,

earray[0].TypeName(),

earray[0].CalculateCharge(2F));

Console.WriteLine(“{0} charge = {1}”,

earray[1].TypeName(),

earray[1].CalculateCharge(0.75F));

}

}

The Engineer class has been changed by the addition of abstract before the class, which indicates the class is abstract (in other words, has one or more abstract functions) and the addition of abstract before the TypeName() virtual function. The use of abstract on the virtual function is the important one; the one before the name of the class makes it clear that the class is abstract, since the abstract function could easily be buried amongst the other functions.

The implementation of CivilEngineer is identical, except that now the compiler will check to make sure that TypeName() is implemented by both CivilEngineer and ChemicalEngineer.

6. Sealed Classes and Methods

Sealed classes prevent a class from being used as a base class. They’re primarily useful to prevent unintended derivation. For example:

// error

sealed class MyClass

{

MyClass() {}

}

class MyNewClass : MyClass

{

}

This fails because MyNewClass can’t use MyClass as a base class because MyClass is sealed.

Sealed classes are useful in cases where a class isn’t designed with derivation in mind or where derivation could cause the class to break. The System.String class is sealed because strict requirements define how the internal structure must operate, and a derived class could easily break those rules.

A sealed method lets a class override a virtual function and prevents a derived class from overriding that same function. In other words, having sealed on a virtual method stops virtual dispatching. This is rarely useful, so sealed methods are rare.

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 *