Lecture 10: Notes

Here are notes about topics we discussed in lecture 10. For more details about the C# features we discussed, see the Essential C# textbook or the C# reference pages.

optional parameters

You can make a method parameter optional by giving it a default value:

  void fill(int[] a, int start = 0, int val = -1) {
    for (int i = start ; i < a.Length ; ++i)
      a[i] = val;
  }

The caller can omit any optional parameters:

  int[] a = new int[10];
  fill(a);  // same as fill(a, 0, -1)
  fill(a, 5);  // same as fill(a, 5, -1)
  fill(a, 7, 3);

Optional parameters must appear after any non-optional parameters in a parameter list.

named arguments

When you invoke any method, you may precede any argument with a parameter name, followed by a colon.

For example, the Substring method in the string class is defined as

  public string Substring (int startIndex, int length);

We may invoke it in any of these ways:

  string s = "bowling ball";
  string t = s.Substring(1, 3);  // "owl"
  string u = s.Substring(startIndex: 1, length: 3);  // identical
  string v = s.Substring(length: 3, startIndex: 1);  // identical

Notice that named arguments may appear in any order, and must appear after any non-named arguments in an argument list.

If some method parameters are optional, you may specify any subset of them you like using named arguments. For example, we may invoke the fill method from the previous section as

fill(a, val: 4)  // same as fill(a, 0, 4)

covariance

Suppose that we've written a generic class DynArray representing a dynamic array:

class DynArray<T> {
  T[] a = new T[1];
  int count;
  
  public int length { get {  } set {  } }
  
  public void add(T t) {  }
  
  public T this[int index] { get {  } set {  } }
  
  
}

And suppose that we have an abstract class Shape with subclasses Rectangle, Circle and Triangle:

abstract class Shape {
  public abstract double area { get; }
}

class Rectangle : Shape {
  public double width, height;
  public Rectangle(double width, double height) {  }
  
  public override double area { get => width * height; }
}

class Circle : Shape {
  public double radius;
  public Circle(double radius) { this.radius = radius; }

}

class Triangle : Shape {  }

Finally, suppose that we have a method areaSum that adds the area of all Shapes in a dynamic array:

  public static double areaSum(DynArray<Shape> a) {
    double sum = 0;
    for (int i = 0 ; i < a.length ; ++i)
      sum += a[i].area;
    return sum;
  }

Now consider this question: Can we pass a DynArray<Rectangle> to areaSum? In other words, will the following code compile?

  DynArray<Rectangle> r = new DynArray<Rectangle>();
  r.add(new Rectangle(10, 2));
  r.add(new Rectangle(20, 4));
  double a = areaSum(r);   // ???

It will not compile, because the type DynArray<Rectangle> is not convertible to DynArray<Shape>. Such a conversion would be unsafe, since it would allow the following:

  public static void addCircle(DynArray<Shape> a) {
    a.add(new Circle(5.0));
  }

  DynArray<Rectangle> r = new DynArray<Rectangle>();
  addCircle(r);  // ???

More generally, suppose that C is a generic class or interface that has a single type parameter, and that U is a subtype of T. By default, C's type parameter is invariant: the types C<T> and C<U> are not convertible to each other.

Classes' type parameters in C# are always invariant. An interface's type parameters, however, may be marked as covariant using the out keyword. If I is a generic interface with a covariant type parameter and U is a subtype of T, then I<U> is convertible to I<T>.

For example, here is an interface with an invariant type parameter:

interface ReadOnlyArray<out T> {
  int length { get; }
  T this[int index] { get; }
}

Suppose that the DynArray class above implements this interface:

class DynArray<T> : ReadOnlyArray<T> {  }

And suppose that we modify areaSum to take a ReadOnlyArray<Shape> as its argument:

  public static double areaSum(ReadOnlyArray<Shape> a) {
    double sum = 0;
    for (int i = 0 ; i < a.length ; ++i)
      sum += a[i].area;
    return sum;
  }

Now we may pass a DynArray<Rectangle> to areaSum:

  DynArray<Rectangle> r = new DynArray<Rectangle>();
  r.add(new Rectangle(10, 2));
  r.add(new Rectangle(20, 4));
  double a = areaSum(r);  // will now compile

If an interface's type parameter T is covariant, then the interface's methods and properties may not receive any values of type T as parameters. For example, you will get a compile-time error if you attempt to mark the following interface's type parameter T as covariant, since the interface's methods and properties receive values of type T:

interface Arr<T> {
  int length { get; set; }
  void add (T t);
  T this[int index] { get; set; }
}

Contravariance is a complement to covariance. You can mark a type parameter as contravariant using the in keyword. If I is a generic interface with a contravariant type parameter and U is a subtype of T, then I<T> is convertible to I<U>. We will not give examples of contravariance or use it further in this course, however.

nested methods

Methods in C# may be nested. A nested method appears inside the body of another method. For example:

  static void Main1(string[] args) {
    WriteLine("hello");

    double arg(int n) => double.Parse(args[n]);

    double d = arg(0);
    double e = arg(1);    
    WriteLine(d + e);
  }

A nested method may access parameters and local variables in its containing method. For example, the nested method arg above accesses the args parameter.

lambda expressions

A lambda expression is an anonymous function that can appear inside another expression.

For example, here is a delegate type for a function from integers to integers:

delegate int IntFun(int i);

And here is a method that applies a given function to every element of an array of integers:

  static int[] map(int[] a, IntFun f) {
    int[] b = new int[a.Length];
    for (int i = 0 ; i < a.Length ; ++i)
      b[i] = f(a[i]);
    return b;
  }

We can define a named method and pass it to map:

  static int plus2(int i) => i + 2;

  static int[] add2(int[] a) => map(a, plus2);

Alternatively, we can invoke map using a lambda expression:

  static int[] add2(int[] a) => map(a, i => i + 2);

Here, i => i + 2 is a lambda expression. It is an anonymous function that takes an integer parameter i and returns the value i + 2.

Like a nested method, a lambda expression may refer to parameters or local variables in its containing method. For example, suppose that we want to write a method that adds a given value k to each element in an array. We could write a nested method and pass it to map:

  static int[] add_k(int[] a, int k) {
    int f(int i) => i + k;
    return map(a, f);
  }

Or we can use a lambda expression that adds k directly:

  static int[] add_k(int[] a, int k) => map(a, i => i + k);

The lambda expressions in the examples above are expression lambdas, writen using a compact syntax similar to the expression syntax for methods. Alternatively, a lambda expression can be written as a statement lambda, which can include one or more statements and can use the return statement to return a value. For example, we can rewrite the last example above like this:

  static int[] add_k(int[] a, int k) =>
    map(a, i => { return i + k; } );

functions as return values

In the examples above we've seen that a method can take a delegate (i.e. a function) as an argument. A method can also return a delegate constructed using a lambda expression.

Here is a simple example:

  delegate bool IntCond(int i);  // integer condition
  static IntCond divisibleBy(int k) =>
    i => (i % k == 0);

Now we can invoke this method and use the delegate that it returns:

  IntCond div3 = divisibleBy(3);
  WriteLine(div3(6));  // writes 'True'
  WriteLine(div3(7));  // writes 'False'

In this example, note that the delegate returned by divisibleBy can refer to the parameter k even after the method divisibleBy has returned! To put it differently, the lambda expression i => (i % k == 0) has captured the parameter k. Local variables may also be captured by a lambda expression.

Using lambda expressions we can write functions that transform other functions. This is a powerful technique that you may explore further in more advanced courses about functional programming. Here are just a couple of examples of this nature. First, here is a function that composes two functions f and g, returning the function (f g), which is defined as (f g)(x) = f(g(x)):

  static IntFun compose(IntFun f, IntFun g) =>
    i => f(g(i));

We can call compose as follows:

  IntFun f = compose(i => i * 2, i => i + 1);  // now f(x) = 2 * (x + 1)
  WriteLine(f(4));   // writes 10

Second, here's a function that computes the nth power of a function f, defined as

fn(x) = f(f(...(f(x))) [ f appears n times in the preceding expression ]

  static IntFun power(IntFun f, int n) =>
    i => {
      for (int j = 0 ; j < n ; ++j)
        i = f(i);
      return i;
    };

Addition raised to a power is multiplication:

  IntFun f = power(i => i + 10, 4);
  WriteLine(f(2));  // writes 42

higher-order functions on arrays

Suppose that we'd like to write a method that computes the sum

12 + 22 + 32 + … + n2

for any given value of n.

Here is an iterative implementation:

  static int sumSquares(int n) {
    int s = 0;
    for (int i = 1 ; i <= n ; ++i)
      s += i * i;
    return s;
  }

Alternatively, we can compute the sum recursively:

  static int sumSquares(int n) =>
    n == 0 ? 0 : n * n + sumSquares(n  1);

A third possible approach is to compute the sum using functions that operate on sequences of integers. In other words, we would like to be able to write

  static int sumSquares(int n) =>
    range(1, n).map(i => i * i).sum();

To do this we will need appropriate implementations of range, map and sum. One way to implement range, map and sum is using arrays. range can return an array of integers, and we can write map and sum as extension methods on arrays of integers:

  static int[] range(int start, int end) {
    int[] a = new int[end - start + 1];
    
    for (int i = 0 ; i < a.Length ; ++i)
      a[i] = start + i;
      
    return a;
  }
  
  static int[] map(this int[] a, IntFun f) {
    int[] b = new int[a.Length];
    for (int i = 0 ; i < a.Length ; ++i)
      b[i] = f(a[i]);
    return b;
  }
  
  static int sum(this int[] a) {
    int s = 0;
    foreach (int i in a)
      s += i;
    return s;
  }

Here, map is a higher-order function since it takes a function as an argument. It is possible to write many other useful higher-order functions that operate on sequences. For example, the filter function selects only those values in a sequence that satisfy an arbitrary condition:

  static int[] filter(this int[] a, IntCond c) {
    List<int> b = new List<int>();
    foreach (int i in a)
      if (c(i))
        b.Add(i);
    return b.ToArray();
  }

By chaining together higher-order functions such as these we can write compact code that manipulates sequences in arbitrary ways. This is a powerful technique that is popular in functional programming. For example, here is a function that computes the sum of the squares of all odd integers in a sequence:

  static int sumOddSquares(int[] a) => a.filter(i => i % 2 == 1).map(i => i * i).sum();

We can make these higher-order functions generic. To do so, we will need a delegate type for a function from any type T to any type U. The standard library contains a delegate type Func that serves this purpose:

  delegate U Func<T, U>(T arg);

Similarly, the standard library contains a delegate type Predicate that maps any type T to bool:

  delegate bool Predicate<T>(T obj);

Here are generic versions of map and filter on arrays:

  static U[] map<T, U>(this T[] a, Func<T, U> f) {
    U[] b = new U[a.Length];
    for (int i = 0 ; i < a.Length ; ++i)
      b[i] = f(a[i]);
    return b;
  }  
  
  static T[] filter<T>(this T[] a, Predicate<T> c) {
    List<T> b = new List<T>();
    foreach (T t in a)
      if (c(t))
        b.Add(t);
    return b.ToArray();
  } 

higher-order functions on streams

In the previous section we wrote higher-order functions that operate on arrays. Unfortunately operating only on arrays is a serious limitation, for several reasons:

In previous lectures we've seen that we can represent an arbitrary stream of values using an interface. For example, we can represent a stream of integers as follows:

interface IntStream {
  int? next();
}

More generally, we can represent a stream of values of type T like this:

interface Stream<T> {
  bool next();  // true if there are any more values
  T val { get; }  // the current value
}

It is possible to adapt the map and filter functions above to work on IntStream or (with a bit more work) Stream objects. I will leave this as an exercise for you.

We have also seen that the C# class library contains its own interfaces for representing streams of values, namely IEnumerable<T> and IEnumerator<T> in the System.Collections.Generic namespace. These interfaces are similar in spirit to Stream<T>, but are a bit more complex. All built-in collection classes such as List<T>, HashSet<T> and Dictionary<T> implement IEnumerable<T>.

The C# class library also contains a set of (mostly) higher-order functions that operate on arbitrary streams, i.e. values of type IEnumerable<T>. These are found in the System.Linq namespace, and many of them are documented in our C# library quick reference. These functions are extremely useful since they can operate on any type of collection, so you should become familiar with them. These functions include, for example, Select, which is like our map function above, and Where, which is like our filter function above.

Consider once again the method we wrote above that computes the sum of the squares of the values from 1 to n:

  static int sumSquares(int n) =>
    range(1, n).map(i => i * i).sum();

We can write this method using the C# collection classes as follows:

  static int sumSquares(int n) =>
    Enumerable.Range(1, n).Select(i => i * i).Sum();

Note that Enumerable.Range returns an enumeration of values from 1 to n, but does not store all these values in memory simultaneously. So this second implementation will use much less memory than our array-based implementation when n is large.

Note that C# also contains a special syntax called LINQ that allows you to write SQL-like expressions in C# code; the C# compiler translates these expressions into calls to methods in the System.Linq namespace. We will not be covering the LINQ syntax in this course.

You may wish to write your own methods that operate on C# enumerations. You can do so by writing classes derived from IEnumerable<T> and by implementing all the various methods in the IEnumerable<T> and IEnumerator<T> interfaces, but that can be quite laborious. Fortunately there is a much easier way, which we will learn about in the next lecture.