string vs. StringBuilder

Typ string je striktně immutable a tedy nelze existující instanci textového řetězce modifikovat. Můžu pouze vyrobit instanci novou, s jiným obsahem. Vezměme si např. následující funkci, která vrátí seznam čísel jako textový řetězec s daným oddělovačem:

string ConcatenateNumbers(List<int> numbers, char delimiter) {
    string result = "";
    foreach (int number in numbers) {
        result += number
        result += delimiter
    }
    if (result.Length != 0) // Possibly remove trailing delimiter
        result = result.Remove(result.Length - 1);
    return result;
}

Volání takové funkce jako ConcatenateNumbers([1,2,3,4], '-') vrátí textový řetězec s obsahem 1-2-3-4. Jelikož je string immutable, pak se v každé iteraci cyklu musí vytvořit nový řetězec a protože string je referenční typ, každý nový řetězec znamená alokaci na haldě. Zamyslete se, jaké objekty vzniknou na haldě během volání této metody (odpověď)

Přidávání jednoho znaku do textového řetězce je tedy lineární algoritmus. Řešením tohoto problému je třída StringBuilder. Implementaci si můžeme představit podobně jako je implementovaný List<char>, tedy pole s větší velikostí (kapacitou) než je počet položek (Length). Přidání prvku do listu pak většinou znamená pouze inkrementovat počet položek a zapsat znak do správného místa v poli. A když v poli místo dojde, vyrobí se nové pole s dvojnásobnou velikostí a obsah původního pole se do něj nakopíruje. Průměrně (resp. amortizovaně) je pak přidání znaku konstantní algoritmus. V efektivnější implementaci originální metody se pak na haldě alokují tyto objekty:

string ConcatenateNumbers(List<int> numbers, char delimiter) {
    var result = new StringBuilder();
    foreach (int number in numbers) {
        result.Append(number)
        result.Append(delimiter)
    }
    if (result.Length != 0) // Possibly remove trailing delimiter
        result.Length--;
    return result.ToString();
}

Pozor, že reálná implementace StringBuilder je mnohem sofistikovanější a je založena na analýze častých způsobu jeho používání za celou existenci jazyka C#!

Typ StringBuilder můžeme chápat jako modifikovatelný textový řetězec. S tím musíme pak počítat při programování. Pokud instanci typu StringBuilder předáme nějaké metodě, musíme počítat s tím, že nám metoda text změní. U typu string máme na druhou stranu garantované, že se to stát nemůže.

Jednoznakový string vs. char

Pro reprezentaci mezery v programu máme dvě varianty:

Ač obě varianty v kódu vypadají podobně (v jazyce Python jsou si dokonce tyto zápisy ekvivalentní), je mezi nimi velmi zásadní rozdíl.

Typ char, je hodnotový typ. Velikosti proměnné je 2B, v paměti je uložená hodnota 0x20 reprezentující mezeru.

Typ string, je referenční typ. Velikost proměnné je typicky 8B (v závislosti na platformě), ve které je uložená reference na haldu, kde se nachází instance. Instance samotná pak má (typicky, v závislosti na platformě) (8+8)B za overhead, 2B za jeden znak (s hodnotou 0x20) a případně ještě další data, které string potřebuje pro interní implementaci (jako např. jeho velikost).

Mezi použitím stringBuilder.Append(' ') a stringBuilder.Append(" ") není rozdíl jenom paměťový, ale i časový. Sami se zamyslete, že implementace metody Append(char) je jistě jednodušší, než implementace metody Append(string) (důvod).

System.Linq

Jmenný prostor (namespace) System.Linq obsahuje funkcionalitu, která je velmi užitečná pro zkušeného C# programátora. Momentálně nám však ještě chybí zásadní znalosti některých konceptů jazyka C#, které jsou potřebné pro pochopení všech aspektů (a důsledků implementace) typů a metod v tomto jmenném prostoru. Bohužel tím, že se jedná o zásadní část jazyka C#, tak značné množství zdrojů (včetně LLM) používá tyto funkce a velmi často je používá nevhodně, někdy dokonce naprosto špatně. Pro naše vlastní dobro si tedy prozatím zakažme používat tento jmenný prostor pro úlohy v tomto semestru.

Jako příklad si vezměme typ Queue<int> reprezentující frontu (FIFO) celých čísel. V rámci tohoto typu lze efektivně přidávat na její začátek (metoda Enqueue), odebírat z jejího konce (metoda Dequeue) a zjistit počet prvků ve frontě (vlastnost Count). Typ Queue nicméně nedisponuje možností zjistit co je její druhý, třetí nebo N-tý (jiný než první) prvek. System.Linq nám tuhle možnost přidává, pomocí metody ElementAt(int index). Pokud tedy chceme vytisknout obsah fronty, můžeme použít následující metodu:

void PrintQueue(Queue<int> queue) {
    for (int i = 0; i < queue.Count; ++i)
        Console.WriteLine(queue.ElementAt(i));
}

Může to znít překvapivě, ale asymptotická složitost této metody je kvadratická vzhledem k velikosti fronty. To je z toho důvodu, že metoda ElementAt nemá jinou možnost jak zjistit co je N-tý prvek fronty než následovně:

int ElementAt(int selectedIndex) {
    Queue<int> queue = this;
    int currentIndex = 0;
    foreach (int element in queue) {
        if (currentIndex++ == selectedIndex)
            return element;
    }
}

Kolekce (v tomto případě fronta) se tedy pro každé ElementAt(i) znovu a znovu prochází od začátků, aby se dopočítalo k jejímu i-tému prvku.

Tohle platí obecně pro všechny metody, které System.Linq dodává do standardních kolekcí. Pro některé situace se sice dají vymyslet optimalizace, ale není možné se na jejich přítomnost spolehnout.

Programátorské konvence v C#

Programátorskou konvencí rozumíme sadu pravidel, jak psát kód v daném jazyce tak, aby se usnadnila čtenářům (ostatním programátorům) orientace v našem kóde. V prvé řadě je nutné si uvědomit, že to, jak se píše kód v jazyce C#, se liší od psaní kódu v jiných jazycích (např. C++). Rovněž platí, že konvence v týmu Projekt 1 může být rozdílná než jakou používá tým Projekt 2. V této sekci představíme na co jsou obecně zvyklí C# programátoři (stejnou konvenci mimo jiné používá i samotná implementace jazyka C#).

Jazyk psaní identifikátorů by měl být konzistentní napříč celým projektem. Dneska i menší týmy programátorů používají angličtinu pro psaní kódu. Větší (mezinárodní) týmy pak ani jinou možnost nemají.

Následuje kód, který demonstruje jak psát názvy různých identifikátorů (typy, položky, vlastnosti, metody, …):

public class ClassPascalCase {
    public int PublicFieldPascalCase;
    public int PublicPropertyPascalCase { get; set; }

    public static int PublicStaticFieldPascalCase;
    public static int PublicStaticPropertyPascalCase { get; set; }

    public const int PublicConstantPascalCase = 42;

    public int GetVerbPublicMethodPascalCase(int camelCaseArgumentOne) {
        int localVariableCamelCase = 5;
        return 1 + localVariableCamelCase + camelCaseArgumentOne + _privateField + s_privateStaticField;
    }

    private int GetVerbPrivateMethodPascalCase(int camelCaseArgumentTwo) {
        return 2;
    }

    private int PrivatePropertyPascalCase { get; set; }
    private const int PrivateConstantPascalCase = 42;

    private int _privateField; // Using underscore as a prefix is fairly new and not always recognized.
    private static int s_privateStaticField; // On the other hand it's very clear which data is being accessed by a method.
}

public struct StructPascalCase {
    public int PublicField;
}

public interface IPascalCase { // We use I as a prefix for interfaces.
    public void StartVerbPascalCase(); // Method name should include a verb so that it's clear that some action will be performed when invoking the method.
}

public class SpecificThingWentWrongException : Exception { // We use Exception as a suffix for exceptions.
}

public record class RecordPascalCase(int PascalCaseProperty, long PascalCaseOtherProperty);

public class ClassPrimaryConstructor(int ctorArgumentCamelCase, int _capturedCtorArgument) {
    public int PublicReadOnlyProperty { get; } = ctorArgumentCamelCase; // We do not use ctorArgumentCamelCase
    // outside of initializations of non-record,
    // so it does NOT get captured by copy
    // into a private field.

    public int CalcPublicValue() {
        return _capturedCtorArgument;   // We use _capturedCtorArgument here outside of initializations of non-record,
        // so the name "_capturedCtorArgument" represents the private field
        // with the captured value of primary ctor argument here !!!
    }
}

public class SomeClassTests {
    public void MethodTestPascalCase_PascalCase_PascalCase() { // Using underscore in test methods is alright to further differentiate between various method use scenarios.

    }
}