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.
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.
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)
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.
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.
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; } );
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
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(); }
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:
We may wish to perform the same operations (map, filter and so on) on other types of collections such as linked lists, dynamic arrays or hash tables.
We may wish to perform these operations on sequences of values that are not stored in memory in a collection at all. For example, if we want to add the squares of the integers from 1 to 1,000,000, it wastes memory to have to places all of these integers into an array in order to operate on them.
We may wish to perform these operations on infinite sequences of values, which can certainly not be stored in an array or other collection class.
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.