6. Records, structs and enums
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
In lesson 5, two class variables could refer to the same object, and a change made through one was visible through the other. Many things a program handles are plain data instead: a position on the neck, a note, the quality of a chord. For them, sharing one object is rarely what you want. C# has three other kinds of types for this: the struct, copied whole each time it is assigned; the record, compared by its data; and the enum, a fixed list of named choices.
Every program of this lesson is in code/csharp-beginner; run one with dotnet run followed by its path, such as examples/l06_copies.cs. check.sh compares their outputs, and the compiler errors of the rejected snippets, with the files in expected/.
Value types and reference types
Section titled “Value types and reference types”FretPosition below is a struct and FretMarker a class. They hold the same two numbers, and the program does the same things to both:
// A struct is a value type: assigning it copies the dataFretPosition first = new FretPosition(6, 3); // G on the low E stringFretPosition second = first;second.Fret = 5;Console.WriteLine($"first: string {first.StringNumber}, fret {first.Fret}");Console.WriteLine($"second: string {second.StringNumber}, fret {second.Fret}");
// A class is a reference type: assigning it copies the reference (lesson 5)FretMarker marker = new FretMarker(6, 3);FretMarker sameMarker = marker;sameMarker.Fret = 5;Console.WriteLine($"marker: string {marker.StringNumber}, fret {marker.Fret}");
// A method receives a copy of a struct, and a copy of the reference to an objectMoveUpOctave(first);MoveMarkerUpOctave(marker);Console.WriteLine($"after the methods: first at fret {first.Fret}, marker at fret {marker.Fret}");
// Equals compares the fields of a struct, the references of a classConsole.WriteLine($"first.Equals(new FretPosition(6, 3)): {first.Equals(new FretPosition(6, 3))}");Console.WriteLine($"marker.Equals(new FretMarker(6, 17)): {marker.Equals(new FretMarker(6, 17))}");
void MoveUpOctave(FretPosition position) => position.Fret += 12;void MoveMarkerUpOctave(FretMarker target) => target.Fret += 12;
struct FretPosition{ public int StringNumber { get; set; } public int Fret { get; set; }
public FretPosition(int stringNumber, int fret) { StringNumber = stringNumber; Fret = fret; }}
sealed class FretMarker{ public int StringNumber { get; set; } public int Fret { get; set; }
public FretMarker(int stringNumber, int fret) { StringNumber = stringNumber; Fret = fret; }}first: string 6, fret 3second: string 6, fret 5marker: string 6, fret 5after the methods: first at fret 3, marker at fret 17first.Equals(new FretPosition(6, 3)): Truemarker.Equals(new FretMarker(6, 17)): FalseA struct is a value type: the variable holds the data itself. second = first copies the two numbers into second, so changing second leaves first alone. A class is a reference type: the variable holds a reference to an object, and sameMarker = marker copies the reference. There is still one marker, and both variables see it move.
flowchart LR first["first: string 6, fret 3"] second["second: string 6, fret 5"] marker["marker"] --> obj["one FretMarker object: string 6, fret 5"] sameMarker["sameMarker"] --> objMethods follow the same rule. MoveUpOctave receives a copy of first and moves the copy, which disappears when the method returns: first is still at fret 3. MoveMarkerUpOctave receives a copy of the reference, so it moves the one marker, from fret 5 to 17. Lesson 4 showed the same thing with an int and an array: the numeric types of lesson 2, bool and char are structs; string, arrays and List<T> are classes.
Comparison follows it too. A struct’s Equals compares the fields one by one (ValueType.Equals), so two separate positions on string 6, fret 3 are equal. A class’s Equals compares references: a new marker at the same place is another object, so it’s not equal.
Two mistakes with structs
Section titled “Two mistakes with structs”A struct you declare has no == operator, unless you write one. The rejected snippet compile_fail/l06_struct_equals.cs tries it:
FretPosition first = new FretPosition(6, 3);Console.WriteLine(first == new FretPosition(6, 3));l06_struct_equals.cs(2,19): error CS0019: Operator '==' cannot be applied to operands of type 'FretPosition' and 'FretPosition'The second mistake comes with lists. shape[0] asks the List<T> for its first item, and for a struct the list returns a copy. Changing a copy that is thrown away at once would change nothing, so the compiler refuses it (compile_fail/l06_list_struct_element.cs):
List<FretPosition> shape = [new FretPosition(6, 3), new FretPosition(5, 5)];shape[0].Fret = 5;l06_list_struct_element.cs(2,1): error CS1612: Cannot modify the return value of 'List<FretPosition>.this[int]' because it is not a variableRead the item into a variable, change the variable, and store it back with shape[0] = position;. Exercise 3 does the same in one step, with with.
The structs of this section are mutable only to show the copy. Microsoft’s guidelines on choosing between a class and a struct advise a struct only for a small value that doesn’t change once created, and a readonly struct makes the compiler enforce that. The next section shows the short way to write one.
Records: compared by their data
Section titled “Records: compared by their data”A record declares, in one line, a type made of named values:
// A record: the compiler writes the constructor, the properties, ToString and equalityNote c4 = new Note("C", 4);Note middleC = new Note("C", 4);Note c5 = c4 with { Octave = 5 }; // a copy with one property changed
Console.WriteLine(c4);Console.WriteLine(c5);Console.WriteLine($"c4 == middleC: {c4 == middleC}");Console.WriteLine($"same object: {ReferenceEquals(c4, middleC)}");Console.WriteLine($"c4 == c5: {c4 == c5}");
// A class with the same data compares referencesNoteObject a = new NoteObject("C", 4);NoteObject b = new NoteObject("C", 4);Console.WriteLine($"two NoteObject: {a == b}");
// A record struct is a value type with the same conveniencesPosition g = new Position(6, 3);Position a2 = g with { Fret = 5 };Console.WriteLine($"{g} -> {a2}");Console.WriteLine($"g == new Position(6, 3): {g == new Position(6, 3)}");
record Note(string Name, int Octave);
readonly record struct Position(int StringNumber, int Fret);
sealed class NoteObject{ public string Name { get; } public int Octave { get; }
public NoteObject(string name, int octave) { Name = name; Octave = octave; }}Note { Name = C, Octave = 4 }Note { Name = C, Octave = 5 }c4 == middleC: Truesame object: Falsec4 == c5: Falsetwo NoteObject: FalsePosition { StringNumber = 6, Fret = 3 } -> Position { StringNumber = 6, Fret = 5 }g == new Position(6, 3): Truerecord Note(string Name, int Octave); gives Note a constructor with two parameters, a public property for each, a ToString that prints them, and an Equals and an == that compare them. Compare it with the eleven lines of NoteObject, which still has no ToString and compares references.
A record is a class: c4 and middleC are two objects, as ReferenceEquals confirms, and yet c4 == middleC is True, because their data is the same. The with expression makes a copy with some properties changed: c5 is a new note, and c4 is still in octave 4.
readonly record struct Position(...) is the same idea for a value type: copied on assignment like the structs of the previous section, with a == and a readable ToString, and properties that can’t change once the value is built.
The properties of Note are init-only: they can be given a value while the record is created, never after. The rejected snippet compile_fail/l06_record_init_only.cs tries c4.Octave = 5;:
l06_record_init_only.cs(2,1): error CS8852: Init-only property or indexer 'Note.Octave' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.To get a note in another octave, write c4 with { Octave = 5 }. In a plain record struct, without readonly, the properties declared between the parentheses can change after creation; the records page lists the differences.
Guitar Alchemist declares its fretboard positions this way: public readonly record struct PositionLocation(Str Str, Fret Fret), at commit 5c3a52a. Its Str and Fret are themselves readonly record struct types, rather than bare ints.
Enums: a fixed set of names
Section titled “Enums: a fixed set of names”A chord’s quality is one of a few choices: major, minor, diminished… An enum gives each choice a name:
// An enum names a fixed set of choices; each name stands for a numberChordQuality quality = ChordQuality.Minor;Console.WriteLine(quality);Console.WriteLine((int)quality);Console.WriteLine($"A{Suffix(quality)}");
foreach (ChordQuality q in Enum.GetValues<ChordQuality>()){ Console.WriteLine($"{(int)q} {q}: C{Suffix(q)}");}
ChordQuality unset = default; // the value 0ChordQuality zero = 0; // the literal 0 converts without a castConsole.WriteLine($"default: {unset}, 0: {zero}");
ChordQuality strange = (ChordQuality)7; // a cast accepts any intConsole.WriteLine($"cast from 7: {strange}, defined: {Enum.IsDefined(strange)}");
ChordQuality parsed = Enum.Parse<ChordQuality>("Diminished");Console.WriteLine($"parsed: {parsed} = {(int)parsed}");
string Suffix(ChordQuality q) => q switch{ ChordQuality.Major => "", ChordQuality.Minor => "m", ChordQuality.Diminished => "dim", ChordQuality.Augmented => "aug", _ => "?",};
enum ChordQuality{ Other, Major, Minor, Diminished, Augmented,}Minor2Am0 Other: C?1 Major: C2 Minor: Cm3 Diminished: Cdim4 Augmented: Caugdefault: Other, 0: Othercast from 7: 7, defined: Falseparsed: Diminished = 3Each name stands for an int, numbered from 0 in the order of the declaration: Minor is 2. Console.WriteLine prints the name, and the cast (int) gives the number. Enum.GetValues<ChordQuality>() lists the values, sorted by their number, and Enum.Parse turns a name written as text back into a value. A switch expression, from lesson 3, is the usual way to give each choice its own result.
An enum is a value type, and its default value is 0, the first name. Microsoft’s enum design guidelines therefore ask for a member worth zero that makes sense as a default. Guitar Alchemist’s ChordQuality starts with Other for that reason; this lesson’s smaller enum copies the idea.
Numbers and names don’t mix freely. The literal 0 converts to any enum on its own, as zero shows, but any other number needs a cast. The rejected snippet compile_fail/l06_int_to_enum.cs writes ChordQuality quality = 2;:
l06_int_to_enum.cs(1,24): error CS0266: Cannot implicitly convert type 'int' to 'ChordQuality'. An explicit conversion exists (are you missing a cast?)The cast, in turn, checks nothing: (ChordQuality)7 is accepted and prints 7, a value with no name. Enum.IsDefined says whether a value has a name. The enum reference warns about both conversions.
A switch expression over an enum still needs _
Section titled “A switch expression over an enum still needs _”Because an enum can hold numbers without a name, a switch expression with one arm per name is still not complete. examples/l06_enum_switch_warning.cs:
ChordQuality quality = (ChordQuality)7;
// Every name has an arm, and still the compiler warns: an enum can hold other numbersstring suffix = quality switch{ ChordQuality.Other => "?", ChordQuality.Major => "", ChordQuality.Minor => "m", ChordQuality.Diminished => "dim", ChordQuality.Augmented => "aug",};Console.WriteLine(suffix);l06_enum_switch_warning.cs(4,25): warning CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(ChordQuality)5' is not covered.Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.Unmatched value was 7.The lines of the stack trace, which start with at, are left out. The warning is CS8524, a cousin of lesson 3’s CS8509. Its example, (ChordQuality)5, is the first number after the last name, not the 7 that the program fails on. The _ => "?" arm of Suffix above handles both.
Exercises
Section titled “Exercises”Exercise 1 — predict the copies
Section titled “Exercise 1 — predict the copies”Without running it, predict what exercises/l06_ex_predict.cs prints. Tempo is a struct, Metronome a class.
Tempo a = new Tempo(120);Tempo b = a;b.Bpm = 90;
Metronome m1 = new Metronome(120);Metronome m2 = m1;m2.Bpm = 90;
Console.WriteLine($"{a.Bpm} {b.Bpm} {m1.Bpm} {m2.Bpm}");
struct Tempo{ public int Bpm { get; set; }
public Tempo(int bpm) => Bpm = bpm;}
sealed class Metronome{ public int Bpm { get; set; }
public Metronome(int bpm) => Bpm = bpm;}Solution
120 90 90 90b = a copies the tempo: a keeps 120. m2 = m1 copies the reference: there is one metronome, and it’s at 90 through both variables.
Exercise 2 — the chords of C major
Section titled “Exercise 2 — the chords of C major”Declare an enum ChordQuality with Other, Major, Minor and Diminished, and a record Chord(string Root, ChordQuality Quality) with a Symbol() method that returns C, Dm or Bdim. Put the seven chords of C major in a List<Chord>, print their symbols on one line, then check with Contains whether the list holds a new Chord("A", ChordQuality.Minor), and the same chord made major with with. The tested solution is exercises/l06_ex_chords.cs.
Solution
List<Chord> cMajor =[ new Chord("C", ChordQuality.Major), new Chord("D", ChordQuality.Minor), new Chord("E", ChordQuality.Minor), new Chord("F", ChordQuality.Major), new Chord("G", ChordQuality.Major), new Chord("A", ChordQuality.Minor), new Chord("B", ChordQuality.Diminished),];
List<string> symbols = [];foreach (Chord chord in cMajor){ symbols.Add(chord.Symbol());}Console.WriteLine(string.Join(" ", symbols));
Chord am = new Chord("A", ChordQuality.Minor);Chord aMajor = am with { Quality = ChordQuality.Major };Console.WriteLine($"Contains {am.Symbol()}: {cMajor.Contains(am)}");Console.WriteLine($"Contains {aMajor.Symbol()}: {cMajor.Contains(aMajor)}");
record Chord(string Root, ChordQuality Quality){ public string Symbol() => Quality switch { ChordQuality.Major => Root, ChordQuality.Minor => $"{Root}m", ChordQuality.Diminished => $"{Root}dim", _ => $"{Root}?", };}
enum ChordQuality{ Other, Major, Minor, Diminished,}C Dm Em F G Am BdimContains Am: TrueContains A: FalseContains compares with Equals. The am object was never put in the list, but a record compares data, so it finds the list’s own A minor. With a class that compares references, like NoteObject, the same search answers False. A record can have methods after its declaration line, between braces, like a class.
Exercise 3 — move a chord shape
Section titled “Exercise 3 — move a chord shape”List<Position> shape = [new Position(6, 3), new Position(5, 5), new Position(4, 5)]; is a G5 power chord, with readonly record struct Position(int StringNumber, int Fret). Move the shape two frets up, to A5, and print it as 6:5 5:7 4:7. shape[i].Fret += 2 is rejected: Position’s properties are init-only, and the compiler answers with the CS8852 error of the records section (compile_fail/l06_readonly_position.cs). The tested solution is exercises/l06_ex_move_shape.cs.
Solution
// G5, a power chord: string 6 fret 3, string 5 fret 5, string 4 fret 5List<Position> shape = [new Position(6, 3), new Position(5, 5), new Position(4, 5)];
// shape[i].Fret += 2 is rejected: replace each element with a moved copyfor (int i = 0; i < shape.Count; i++){ shape[i] = shape[i] with { Fret = shape[i].Fret + 2 };}
List<string> cells = [];foreach (Position p in shape){ cells.Add($"{p.StringNumber}:{p.Fret}");}Console.WriteLine(string.Join(" ", cells));
readonly record struct Position(int StringNumber, int Fret);6:5 5:7 4:7with builds a moved copy, and shape[i] = … stores it in place of the old item. With a readonly type, it’s the only way: even a local Position variable can’t have its Fret changed, and the compiler answers CS8852 again.
Check your understanding
Section titled “Check your understanding”- A struct is a value type: assigning it, or passing it to a method, copies the data.
- A class is a reference type: assigning it copies the reference, and both variables reach the same object.
- A record compares its data with
==andEquals, prints it withToString, and makes modified copies withwith. - An enum names a fixed set of choices; each name stands for a number, and the default is 0.
- A cast to an enum accepts any number: check with
Enum.IsDefined, and give aswitchexpression a_arm.
Next: interfaces and inheritance, where classes start sharing behavior.
Sources
Section titled “Sources”- Value types, reference types, structure types, default values
- Records, records in C# fundamentals, the
withexpression - Equality comparisons, equality operators,
ValueType.Equals,Object.ReferenceEquals - Enumeration types,
Enum.GetValues,Enum.IsDefined,Enum.Parse - Design guidelines: choosing between class and struct, enum design
- Compiler warning CS8524