The type string
represents an immutable sequence
of characters. A string constant is enclosed in double quotes,
e.g. "hello"
.
We can access characters in a string using the [] operator:
string w = "watermelon"; char c = w[0]; // now c = 'w' char d = w[2]; // now c = 't'
An index preceded by ^ counts from the end of the string (like a negative index in Python):
char e = w[^1]; // now e = 'n'
It's possible to extract a range of characters from a string:
string w = "watermelon"; string x = w[2..5]; // now x = "ter" string y = w[5..]; // now y = "melon"
Notice that the range above includes all characters from the
character w[2]
up to but not
including the character w[5]
.
(It's just like "w[2:5]
" in
Python.)
The +
operator
concatenates strings:
string s = "one two"; string t = "three four"; string u = s + " " + u; // "one two three four"
It may be suprising that you cannot compare strings using operators such as < and >:
if ("chameleon" < "dog") // ERROR: operator '<' can't be applied to strings Console.WriteLine("less");
That's because the comparison order of strings may be
language-dependent. For example, "chameleon" appears before
"dog" in an English dictionary, but in a Czech dictionary
"chameleon" appears later since in Czech "ch" is
considered to be a single letter that comes after "h" in
the alphabet. So C# disallows this comparison. (Many other languages
such as Python are more liberal in this respect and will allow
strings to be compared using < or >
.)
Because integer types in C# (unlike Python) have a fixed size, an integer calculation may overflow, i.e. go past the range of allowable values. In this case the higher-order bits of the value will be lost, which effectively computes the value mod 2b for a type with b bits. For example:
uint
i
=
2_000_000_000
;
i
*=
3
;
Console.WriteLine(i);
6,000,000,000 is outside the range of a 32-bit unsigned integer, so the calculation will overflow and this program will print
1705032704
This value is 6,000,000,000 mod 232.
Overflow for a signed integer type may even produce a negative value:
int i = 1_000_000_000; WriteLine(i * 3); // writes -1294967296
C# will perform an implicit conversion between two numeric types if every possible value of the source type is valid in the destination type. For example:
int i; ... long l = i; // implicit conversion
This sort of conversion is called a widening conversion because, for example, a long has a wider range than an int.
You can use an explicit conversion to force a conversion between any two numeric types. This is accomplished with a type cast:
ulong l = 5_000_000_000; uint i = (uint) l;
If the destination type cannot hold the source value, it will wrap around or be truncated. In the example above c will be 705,032,704, which is 5,000,000,000 mod 232.
The conversion from ulong to uint is a narrowing conversion because uint has a narrower range than ulong. C# requires a type cast for any narrowing conversion.
You can implicitly convert a char
to any numeric type that can hold a 16-bit unsigned value:
char c = 'ř'; int i = c; // now i holds the Unicode value for 'ř', i.e. 345
You can explicitly convert any numeric type to a char
:
int i = 100; char c = (char) i; // now c is 'd', which is ASCII/Unicode character 100
Here are two built-in types that represent floating-point numbers:
float
– 32 bits, 7 significant digits (approximate range: 10-45
to 1038)
double
– 64 bits, 15-16 significant digits (approximate range:
10-324 to 10308)
The type of a floating-point constant (e.g. 3.4
)
is double
by default, so this will fail:
float f = 3.4; // error: can't implicitly convert double to float
To create a constant of type float
,
append the character 'f':
float f = 3.4f; // OK
I generally recommend using double
instead of float
, since 64-bit
floating-point operations are inexpensive on modern CPUs. (On the
other hand, if you have a large array of floating-point values, you
can save memory by using float
as the
element type if you don't care about the extra precision of a
double
.)
Be careful with division. The following code will print 0, not 0.6:
int i = 3, j = 5; double d = i / j; // integer division! Console.WriteLine(d); // prints 0
That's because the division operator /
will perfom an integer division when given two integer values.
To perform a floating-point division, use a type cast:
int i = 3, j = 5; double d = (double) i / j; // floating-point division Console.WriteLine(d); // prints 0.6
Alternatively, you may multiply by 1.0 to convert an integer to a double:
double d = 1.0 * i / j; // floating-point division
Note that a float
or double
may hold the special values NegativeInfinity
or PositiveInfinity
, which are sometimes
useful. For example:
double d = double.PositiveInfinity;
Every type in C# is either a value type or
a reference type. Of the types we have seen so far, value
types include all numeric types, bool
,
and char
. The only reference type we
have seen so far is string
. (Soon we
will see many more.)
When a variable's type is a value type, the variable holds a value. An assignment between variables of value type copies the value from one variable to another:
int i = 10; int j = i; // i and j have independent copies of the value 10
When a variable's type is a reference type, the variable holds a reference to an object. An assignment between variables of reference type makes them refer to the same object:
string s = "antelope"; string t = s; // now s and t point to the same 8-character string in memory
C# includes a special value null
,
which is like None
in Python. It
represents the absence of a value.
Basic types cannot hold a null value:
int x = null; // error: cannot convert null to int
However, you can add the suffix ?
to any
type to make it nullable. For example, int?
is a type that holds either an int, or null:
int? x = null; int? y = 7;
If we have a variable of a nullable type such as int?
,
we must test whether its value is null before placing it into a
variable with a non-nullable type such as int
.
For example:
int? i = ...; if (i != null) { int j = (int) i;
... } string? s = ...; if (s != null) { string t = s; ... }
Notice above that we must use a type cast when converting int?
to int
(even after we have checked for
null), but not when we convert string?
to string
. More generally, the type
cast is necessary when converting T? to T for any value type T, but
not for reference types such as string
.
(This is related to the fact that a conversion such as int?
to int
actually changes the
representation of the data through a process known as unboxing.
There is no unboxing for reference types.)
If we omit the null checks above, the compiler will issue a warning:
int? i = ...; int j = (int) i; // warning: value may be null string? s = ...; string t = s; // warning: value may be null
As an alternative to checking for null explicitly, we can use the null-forgiving operator !, which throws an exception if a value is null, and otherwise produces the value unchanged:
int? i = ...; int j = (int) i!; // no warning, but will throw exception if null string? s = ...; string t = s!; // no warning, but will throw exception if null
The Console.ReadLine()
method reads a line of standard input. It returns a value of type
string?
,
which is either a string, or is null
(in the case that no more input is available).
So we can write
string? s = Console.ReadLine(); if (s == null) Console.WriteLine("no more input"); else Console.WriteLine(s);
Often we don't want to worry about the null case, so to keep things simple we use the null-forgiving operator:
string s = Console.ReadLine()!;
We have learned about various built-in types in C#
including int
, uint
,
long
, ulong
,
bool
, char
,
and string
. Actually each of these names
is an abbreviation for a longer name:
int = System.Int32
uint = System.UInt32
long = System.Int64
ulong = System.UInt64
bool = System.Boolean
char = System.Char
string = System.String
You may use either the short or long names in code:
int i = 44; System.Int32 i = 44; // equivalent
Our C# quick reference is organized using the long names, and lists some useful constants and methods for these types.
For example, the section System.Int32
lists constants and methods related to the int
type. The MinValue
and MaxValue
constants may be useful:
int min = int.MinValue; // smallest possible int int max = int.MaxValue; // largest possible int
The quick reference also lists some helpful methods for converting between strings and integers:
FormatException
if the string does not contain a valid integer.Notice that Parse() is a static method. That means that we invoke it on the int type directly:
int x = int.Parse("345");
ToString() is not marked with static, so it is an instance method. We invoke it on an instance of the int type, i.e. an integer itself:
int i = 345; string s = i.ToString();
Later in this course we'll see how to write instance methods and static methods in our own classes.
A string beginning with $
may contain interpolated values
enclosed in braces (just like f-strings in Python):
int a = 3, b = 4; Console.WriteLine($"{a} plus {b} equals {a + b}");
A interpolated value may be followed by a format specifier (preceded by a colon).
There are many predefined format specifiers. Many format specifiers can be followed by an integer called the precision, whose meaning varies among specifiers. Here are two useful specifiers:
f
– prints a
number in fixed-point decimal (i.e. not using scientific notation).
The precision is the number of the digits after the decimal point.
n
– prints a
number using thousands separators. The precision is the number of
digits after the decimal point.
For example,
const double pi = 3.14159; const int i = 1357; Console.WriteLine($"{pi:f2} {i:n0}");
writes
3.14 1,357
Some more format specifiers are listed in our C# quick reference.
A foreach
statement
iterates over each element of a collection:
foreach (char c in "hello") Console.WriteLine(c);
The only kinds of collections we have learned about so far are strings (but we will soon see others).
This statement is similar to the for
statement in Python, which can also loop over various kinds of
collections.
A foreach
statement
always declares an iteration variable whose scope is the body of the
statement.
A do
/while
loop is similar to a while
loop, but
checks the loop condition at the bottom of the loop body. For
example:
string? s; do { s = ReadLine(); } while (s != "yes" && s != "no");
Note that the body of an ordinary while
loop might not execute at all, but the body of a do
/while
loop will always execute at least once.
In most programs do
/while
loops are less common than ordinary while
loops, but they are still occasionally useful.
The conditional operator (?
:
) is similar to the if
statement, but it is an expression, not a statement. It takes
a Boolean value plus two extra values. It returns the first of these
values if the boolean is true, or the second if it is false.
For example, the following statement computes the greater of i and j and assigns it to k:
int i, j; ... int k = i > j ? i : j; // set k to the maximum of i and j
This operator is also sometimes called the ternary operator since it takes three operands. We also saw this operator in Python, which uses a completely different syntax for it:
# Python code: set k to the maximum of i and j k = i if i > j else j;
C# includes fixed-size arrays. You can allocate a single-dimensional array like this:
int[] a = new int[10]; // an array that holds 10 integers
When you allocate an array using the new
operator, all elements will be initialized to the element type's
default value. The default value for int
and other numeric types is 0. The default value for bool
is false.
The default value of every reference type (such as
string
) is null. And so if you create an
array of any reference type, all its elements will initially be null.
That is true even if the type is not nullable! For example:
string[] a = new string[10]; // array of non-nullable strings string?[] b = new string[10]; // array of nullable strings
The elements in both of these arrays will initially be null. However
you cannot assign null to any element of a
:
a[3] = null; // ERROR: converting null to non-nullable type
Arguably this is an inconsistency in the language. It exists for
historical reasons. In older versions of C# there were no nullable
types, and a value of any reference type could be null
.
When nullable types were added to C# a few years ago, the default
value of reference types remained null
,
presumably for backward compatibility.
You may declare an array and initialize its elements to specific values:
int[] a = [ 3, 4, 5 ];
Arrays are indexed from 0, and the ^ operator indexes from the end:
int x = a[1]; // now x is 4 int y = a[^1]; // now y is 5, the last value in the array
An array in C# is a reference type, so more than one variable may refer to the same array. For example:
int[] a = [ 10, 20, 30, 40 ]
;
int[] b = a;
a[0] = 100;
WriteLine(b[0]); // writes 100
We see that the assignment "int[] b = a
"
does not copy the array.
Instead, it sets b to point to the same array as a. (We also saw this
phenomenon in Python.)
You can access ranges of arrays just like for strings:
int[] a = [ 2, 4, 6, 8, 10 ]; int[] c = a[1..4]; // { 4, 6, 8 } int[] d = a[..3]; // { 2, 4, 6 } int[] e = a[3..]; // { 8, 10 }
Note that the array range operator makes a copy of a range of elements (just like slice operators in Python). Of course, it follows that creating a range of N elements will take O(N) time.
The Length
property
returns the length of an array:
int y = a.Length;
An array is a collection, so you can loop over it with foreach
:
foreach (int i in a) Console.WriteLine(i);
Because arrays have a fixed size, you cannot append a value to an
existing array. If you want to append values, probably you should use
a List
, i.e. a dynamic array (we'll
discuss these soon).
The + operator does not append arrays in C#. However, you can use a collection expression to achieve the same effect:
int[] a = [10, 20, 30]; int[] b = [50, 60, 70]; int[] c = [..a, ..b]; // append a and b
More generally, a collection expression can contain individual
elements and/or spread elements such as "..a
"
that include all the elements from an existing collection. So could
write e.g.
int[] a = [10, 20, 30]; int[] b = [5, ..a, 100, ..a, 200];
After the declarations above, b will be [5, 10,
20, 30, 100, 10, 20, 30, 200]
.
The standard library contains useful methods
Split()
, which splits a string into
words, and Join()
, which can join words
into a string:
string s = "one fine day"; string[] words = s.Split(); // now words = ["one", "fine", "day"] string t = string.Join(" ", words); // now t = "one fine day"
In C# you can create two kinds of multidimensional arrays: rectangular arrays and jagged arrays.
You can dynamically allocate a rectangular
array using the new
operator:
int
[,] a =
new
int
[
3
,
4
];
Or you can allocate an array and initialize it with a set of values:
int[,] a = { {1, 4, 5}, {2, 3, 6} };
Note that you must use braces, not brackets, when specifying initial values for a rectangular array.
You can
access an element of a rectangular array using an expression such as
a[
2
,
5
]
.
An rectangular array may even have 3 or more dimensions, e.g.
int[,,] a = new int[5, 10, 15]; // a 3-dimensional array
In a rectangular array a
, a.Length
is the total number of elements in the
array. This is the product of the lengths of all dimensions.
a.Rank
is the number of dimensions, and
the GetLength()
method returns the
length of a given
dimension (numbered from 0):
int[,,] a = new int[5, 10, 15]; // a 3-dimensional array Console.WriteLine(a.Length); // writes 750 (= 5 * 10 * 15) Console.WriteLine(a.Rank); // writes 3 Console.WriteLine(a.GetLength(1)); // writes 10
Length
and Rank
are properties, not methods, so when we access them we don't
write (). A property looks like a field (i.e. an attribute), but is
not exactly the same as a field since code may run when a property is
retrieved. We'll study properties in more detail later in this
course, and will learn how to implement properties in our own
classes.
A jagged array is an array of arrays. Unlike in a rectangular multidimensional array, each subarray in a jagged array can have a different length.
You can allocate a jagged array like this:
int[][] a = new int[3][]; a[0] = new int[2]; a[1] = new int[4]; a[2] = new int[5];
Alternatively, you can initialize a jagged array as you allocate it:
int[][] a = [[2, 3], [3, 4, 5], [1, 3, 5, 7, 9]];
You can access an element of a jagged array using an expression such
as a[
2
][
5
]
.
A dynamic array is an extremely useful data type.
The C# standard library includes a type List<T>
,
which implements a dynamic array. This is just like a list in Python.
The T in List<T>
indicates that this is a generic type. We'll study generics in
more detail later in this course, and will learn how to implement our
own generic types. For now, we only need to understand that T
represents any type. We may construct a List<int>
,
which holds integers, a List<string>
which holds strings, or a list of any other type. Note that (unlike
in Python) all elements of a list must have a common type.
The List<T>
class is in the System.Collections.Generic
namespace. As we saw last week, in a new C# project the implicit
usings feature is enabled by default. With implicit usings, this
namespace is automatically imported, so you can use List<T>
without writing a "using" statement.
However, we also learned last week that implicit
usings are not enabled on ReCodEx. So if you want to use List<T>
in a program that you submit to ReCodEx, you'll need to import its
namespace explicitly:
using System.Collections.Generic;
When we create a list, we may specify initial elements using the same bracket syntax that we used for arrays:
List<
int
>
a
=
[
3
,
5
,
7
];
As another possibility, we can create an empty list and add integers to it in a loop:
List<int> a = []; for (int i = 0 ; i < 10 ; ++i) a.Add(i * i);
The .Add() method is just like .append() in Python: it appends an element to a list, and will run in O(1) on average.
Once we have a list, we may get or set its elements by index:
a[0] = a[1] + a[2]; WriteLine(a[2]);
The Count property retrieves the number of elements in a List<T>
:
WriteLine($"a has {a.Count} elements");
Collection expressions work with List
objects just as with arrays. For example, we may write
List<int> a = [10, 20, 30]; List<int> b = [100, 200, 300]; List<int> c = [..a, ..b]; // concatenate two lists List<int> d = [0, ..a, 0];
We may even use collection expressions to convert between arrays and lists:
int[] a = [2, 4, 6, 8, 10]; List<int> b = [..a]; // convert array to List int[] c = [..b]; // convert List to array
Our C# quick reference lists
more useful methods on List objects. If you look at the documentation
for System.Collections.Generic
in the quick reference, you'll see that List<T>
inherits from IList<T>
, which
inherits from ICollection<T>
,
which inherits from IEnumerable<T>
.
So you'll find methods that work on List
objects not only in the section about List<T>
,
but also in the sections about those supertypes as well. For example,
the Insert()
and RemoveAt()
methods are listed in the documentation for IList<T>
,
and will work on any list. We'll study inheritance and C#'s
collection class hierarchy in more detail in a later lecture.