Here are notes about some topics we covered in lecture 6. For more details, see the Essential C# textbook or the C# reference pages.
Many operators have lifted versions which operate on nullable types.
The unary operators +
, ++
, -
,
--
and !
can act on nullable types. If
their operand is null, they do nothing and return null.
The binary operators +
, -
, *
,
/
and &
can act on nullable types. If
either or both of their operands is null, they return null.
The relational operators <
,
<=
, >
and >=
can act
on nullable types. If either or both of their operands is null, they
return false.
A field marked as readonly
can be modified only in a
constructor or field initializer. This attribute can be used to make
a class immutable:
struct Point { public readonly double x, y; public Point(double x, double y) { this.x = x; this.y = y; } }
In C# (like in Java), a class can implement multiple interfaces, and an interface can inherit from multiple interface. However, a class can inherit from only one other class. In other words, C# supports single class inheritance but multiple interface inheritance.
There are languages such as C++ that allow a class to inherit behavior from multiple superclasses. This adds significant complexity to the language, and C#'s single class inheritance is a design compromise that is adequate in most situations.
A child class constructor can invoke a base class constructor
using the base
keyword. In the example below, the
TwoLimitStack
constructor calls the LimitStack
constructor in this way.
class LinkedStack : Stack { Node head; public virtual void push(int i) { ... } public virtual int pop() { ... } public bool isEmpty { ... } } class LimitStack : LinkedStack { protected int count; protected int limit; public LimitStack(int limit) { this.limit = limit; } public override void push(int i) { if (count == limit) { WriteLine("stack is full"); return; } ++count; base.push(i); } public override int pop() { --count; return base.pop(); } } class TwoLimitStack : LimitStack { int threshold; public TwoLimitStack(int threshold, int limit) : base(limit) { this.threshold = threshold; } public override void push(int i) { if (count == limit - threshold) WriteLine("warning: stack is getting full"); base.push(i); } }
We have already seen the public
and private
access modifiers, which can be attached to class members such as
methods, properties or indexers. public
members are accessible
everywhere; private
members are accessible within a
class. The default access level in C#
is private
.
A third access modifier protected
indicates that a member is visible only within the defining class and
any of its subclasses. The LimitStack
class above includes two protected fields.
A class marked abstract
cannot be instantiated using
the new
operator; only subclasses of it can be
instantiated. Abstact classes may have abstract methods,
which have a method signature but no body. Any concrete
(non-abstract) subclasses of an abstract class must provide an
implementation for every abstract method. Any abstract method is
implicitly virtual, and may not be private
.
Here is an example of an abstract class CountingStack
that maintains a count of objects in a stack. Any concrete subclass
such as LinkedCountingStack
must provide implementations
of the push2
and pop2
methods.
abstract class CountingStack { int _count; protected abstract void push2(int i); protected abstract int pop2(); public void push(int i) { ++_count; push2(i); } public int pop() { --_count; return pop2(); } public bool isEmpty { get => (_count == 0); } public int count { get => _count; } } class LinkedCountingStack : CountingStack { Node head; public override void push2(int i) { head = new Node(i, head); } public override int pop2() { int i = head.i; head = head.next; return i; } }
If a class is marked as sealed
, no other class may
derive from it. If an overriding method is marked as sealed
,
no subclass may override it further.
Sealed classes and methods are mostly useful if you are writing a
library of classes that will be used by someone else. In this case,
you may want to prevent the other programmer from extending your
classes/methods in a way that might break if you change your code.
You will probably use the sealed
modifier rarely in your
own programs.
All types in C# derive from a top-level class called object
.
The full name for this class is System.Object
. The
object class includes the virtual method ToString
and
several other methods such as Equals
, which will learn
more about in a following lecture.
In object-oriented design, sometimes you will have an existing class A and will be designing a new class B that is similar to A. You may sometimes face the choice of whether B should inherit from A or should contain an instance of A as a member.
For example, consider the dynamic array class we saw a couple of lectures ago. Here, we've extended its length property so that it is settable. By setting the length, you can either grow or shrink the array.
class DynArray { int[] a = new int[1]; int count; public int length { get => count; set { if (value < count) count = value; else while (count < value) add(0); } } public void add(int i) { if (count == a.Length) { int[] b = new int[count * 2]; for (int j = 0 ; j < count ; ++j) b[j] = a[j]; a = b; } a[count++] = i; } public int this[int index] { get => a[index]; set => a[index] = value; } }
Now suppose that we want to implement a stack class ArrayStack
that uses a dynamic array as its backing store. Should ArrayStack
inherit from DynArray
, or should it contain a DynArray
as a member?
When considering in general the question of whether B should inherit from A, it is useful to ask whether a B is actually an A in the underlying domain. A pigeon is actually a bird, for example.
Inexperienced programmers sometimes use inheritance when containment is more appropriate. When you inherit from a class, you expose that class's public interface as part of your own class. If your own class does not represent an object that should actually belong to the superclass, this can lead to confusing code.
In this particular example, I would implement ArrayStack
using a DynArray
as a member:
class ArrayStack { DynArray a = new DynArray(); public void push(int i) { a.add(i); } public int pop() { int i = a[a.length - 1]; a.length -= 1; return i; } public bool isEmpty { get => a.length == 0; } }
When writing many object-oriented programs, it is good design practice to use separate classes for the model and the view. A model class implements the underlying data and/or logic in some domain. A view class presents the model to the user in some way, e.g. via a text or graphical interface. The model class knows nothing about the view, and could be reused for various kinds of views. Similarly, the view class should know nothing about the logic of the underlying domain.
Even if a program has only a single view, model/view separation often leads to a nice design, since it partitions the program into two independent and smaller problems.
For example, suppose that you are writing a program to play Tic-Tac-Toe. A model class might hold the state of the board: it knows where each player has played. The model class can also check to see whether either player has won. But a model class should not know that one player's moves will be presented using the character 'X' or 'O', or that the board itself will be displayed using any kind of text representation, such as using the characters "---" to separate squares.