DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

C# Basics Day 1: Types, Strings, Control Flow, Methods and Collections Explained With Five Practice Projects

Why this post exists

Most people learn C# by reading about it. The ones who get good learn it by writing small programs that fight back. This post is Day 1 of a six-day plan to learn C# deeply: Days 1 and 2 cover the basics, Days 3 and 4 the mid-level concepts, Days 5 and 6 the advanced ones, and then you build one large project that uses all of it.

Day 1 covers the language core: types and conversion, strings, control flow, methods, and collections. For each concept you get the same five steps: what it is, an analogy, a short example, a practice question, and a complete solution with the reasons it is written that way. Try the practice question yourself before you read the solution. That is where the learning happens.

All code targets .NET 8 or later and uses top-level statements, so you can paste each solution into the Program.cs of a new console project (dotnet new console) and run it.

Concept 1: Types, variables, constants, operators and type conversion

What it is. C# is statically typed: every variable has a type that is fixed when you write the code, and the compiler refuses to mix types carelessly. A variable stores a value, a const is a value that can never change, operators (+ - * / % == && ||) combine values, and conversion is how you move a value from one type to another.

The analogy: jars in a kitchen. Each jar has a label and only holds one kind of thing: a flour jar, a sugar jar. Pouring a small jar into a bigger one always works (implicit conversion). Pouring a big jar into a small one may spill, so you must say so out loud (an explicit cast like (int)9.99, which drops the .99). Pouring something unknown into a jar is risky, so you check it first: that is what int.TryParse does. A const is a jar with the label engraved in stone.

int age = 30;                   // whole number
double price = 19.99;           // fractional number (binary floating point)
decimal balance = 1050.75m;     // money: exact base-10 arithmetic (note the m)
bool isActive = true;
char grade = 'A';
string name = "Asha";
var count = 10;                 // 'var' lets the compiler infer int; it is still strongly typed

const double Pi = 3.14159;      // fixed at compile time, can never change

int a = 7, b = 2;
Console.WriteLine(a / b);           // 3   (int / int is integer division)
Console.WriteLine(a % b);           // 1   (remainder)
Console.WriteLine((double)a / b);   // 3.5 (cast one side first)

Console.WriteLine(0.1 + 0.2 == 0.3);      // False (double cannot store 0.1 exactly)
Console.WriteLine(0.1m + 0.2m == 0.3m);   // True  (decimal can)

int small = 42;
long big = small;               // implicit conversion: widening, always safe
double d = 9.99;
int truncated = (int)d;         // explicit cast: you accept the loss, result is 9
Console.WriteLine($"{big} {truncated}");

string[] inputs = { "123", "12x", "" };
foreach (string text in inputs)
{
    if (int.TryParse(text, out int parsed))
        Console.WriteLine($"'{text}' -> {parsed}");
    else
        Console.WriteLine($"'{text}' is not a valid int");
}
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • int, double, decimal, bool, char and string are the everyday types. Use decimal for money, because double stores binary fractions and cannot represent 0.1 exactly (the 0.1 + 0.2 == 0.3 line prints False).
  • 7 / 2 is 3, not 3.5, because both sides are int. Cast one side to double first.
  • (int)d is an explicit cast: it truncates, it does not round.
  • TryParse returns true or false and hands the result back through an out variable. It never throws on bad input, which is why it is the right tool for anything a user types.

Practice question 1: temperature converter

Build a console app that converts between Celsius, Fahrenheit and Kelvin. It must read the values from console input and reject invalid input without crashing: unknown scale letters, text instead of numbers, and temperatures below absolute zero.

Solution

using System.Globalization;

const double AbsoluteZeroKelvin = 0.0;

Console.WriteLine("Temperature converter. Type q at any prompt to quit.");

while (true)
{
    Console.Write("Convert from (C/F/K): ");
    string? fromInput = Console.ReadLine();
    if (IsQuit(fromInput)) break;
    if (!TryParseScale(fromInput, out Scale from))
    {
        Console.WriteLine("Please enter C, F or K.");
        continue;
    }

    Console.Write("Value: ");
    string? valueInput = Console.ReadLine();
    if (IsQuit(valueInput)) break;
    if (!double.TryParse(valueInput, NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
    {
        Console.WriteLine("That is not a number.");
        continue;
    }

    double kelvin = ToKelvin(value, from);
    if (Math.Round(kelvin, 6) < AbsoluteZeroKelvin)
    {
        Console.WriteLine("That is below absolute zero, which is physically impossible.");
        continue;
    }

    Console.Write("Convert to (C/F/K): ");
    string? toInput = Console.ReadLine();
    if (IsQuit(toInput)) break;
    if (!TryParseScale(toInput, out Scale to))
    {
        Console.WriteLine("Please enter C, F or K.");
        continue;
    }

    double result = FromKelvin(kelvin, to);
    Console.WriteLine($"{value:F2} {from} = {result:F2} {to}");
}

static bool IsQuit(string? input) =>
    string.Equals(input?.Trim(), "q", StringComparison.OrdinalIgnoreCase);

static bool TryParseScale(string? input, out Scale scale)
{
    switch (input?.Trim().ToUpperInvariant())
    {
        case "C": scale = Scale.Celsius; return true;
        case "F": scale = Scale.Fahrenheit; return true;
        case "K": scale = Scale.Kelvin; return true;
        default: scale = default; return false;
    }
}

static double ToKelvin(double value, Scale scale) => scale switch
{
    Scale.Celsius => value + 273.15,
    Scale.Fahrenheit => (value - 32) * 5 / 9 + 273.15,
    Scale.Kelvin => value,
    _ => throw new ArgumentOutOfRangeException(nameof(scale))
};

static double FromKelvin(double kelvin, Scale scale) => scale switch
{
    Scale.Celsius => kelvin - 273.15,
    Scale.Fahrenheit => (kelvin - 273.15) * 9 / 5 + 32,
    Scale.Kelvin => kelvin,
    _ => throw new ArgumentOutOfRangeException(nameof(scale))
};

enum Scale { Celsius, Fahrenheit, Kelvin }
Enter fullscreen mode Exit fullscreen mode

Sample run:

Temperature converter. Type q at any prompt to quit.
Convert from (C/F/K): C
Value: 100
Convert to (C/F/K): F
100.00 Celsius = 212.00 Fahrenheit
Convert from (C/F/K): F
Value: -500
That is below absolute zero, which is physically impossible.
Convert from (C/F/K): K
Value: x
That is not a number.
Convert from (C/F/K): q
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. Kelvin is the hub. Converting every scale to Kelvin and then from Kelvin means two small formulas per scale instead of six scale-to-scale formulas. Adding Rankine later costs two lines, not a rewrite.
  2. One validation rule covers every scale. Checking kelvin < 0 after converting means you never write separate minimum values (-273.15, -459.67, 0) for each scale.
  3. Math.Round(kelvin, 6) guards against floating-point noise. Converting -459.67 F can land at -0.00000000000003 K, which would wrongly be rejected. Rounding to six places removes that.
  4. TryParse with CultureInfo.InvariantCulture means 36.6 works on every machine, even ones whose regional settings use a comma as the decimal separator. Bad input takes the continue path instead of an exception.
  5. An enum for the scale means the compiler catches a typo like Scale.Kelvn, which a string comparison never would. The switch expressions throw in the _ arm so a future new enum member cannot be silently ignored.
  6. TryParseScale is its own method with an out parameter, the same shape as int.TryParse, so the main loop reads like a list of checks.

Concept 2: Strings, interpolation and StringBuilder

What it is. A string is a sequence of characters. Strings in C# are immutable: no method ever changes a string, every operation such as ToUpper or Replace returns a new one. String interpolation ($"Hello {name}") puts values straight into text. StringBuilder is the tool for building a string piece by piece.

The analogy: a string is a sentence carved in stone. You cannot change a letter; you carve a whole new tablet. Doing that once is fine. Doing it 10,000 times in a loop leaves a pile of discarded tablets for the garbage collector to clear. A StringBuilder is a whiteboard: you write, rub out, and add as much as you like, and you only carve a tablet (ToString()) when you are finished.

using System.Text;

string s = "hello";
string t = s.ToUpper();                 // creates a NEW string; s is untouched
Console.WriteLine($"{s} -> {t}");       // hello -> HELLO

string name = "Asha";
int items = 3;
decimal total = 59.5m;
Console.WriteLine($"{name} bought {items} items for {total:F2}");   // Asha bought 3 items for 59.50

string padded = "  C# is fun  ";
Console.WriteLine(padded.Trim());                          // C# is fun
Console.WriteLine(padded.Contains("fun"));                 // True
string[] parts = "a,b,c".Split(',');
Console.WriteLine(string.Join(" | ", parts));              // a | b | c
Console.WriteLine(string.Equals("Hi", "hI", StringComparison.OrdinalIgnoreCase));   // True

// Building a string in a loop: use StringBuilder
var sb = new StringBuilder();
for (int i = 1; i <= 5; i++)
{
    sb.Append(i).Append(',');
}
sb.Length--;                            // drop the trailing comma
Console.WriteLine(sb.ToString());       // 1,2,3,4,5
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • s.ToUpper() returns a new string and leaves s alone. Forgetting to use the return value is a very common beginner bug.
  • {total:F2} formats to two decimal places. The text after the colon is a format string.
  • string.Equals(..., StringComparison.OrdinalIgnoreCase) is the correct way to compare ignoring case. Avoid a.ToLower() == b.ToLower(), which allocates two throwaway strings.
  • Use StringBuilder when you append in a loop. For a handful of concatenations, plain + or interpolation is simpler and just as fast.

Practice question 2: word-frequency counter and palindrome checker

Write two functions. The first counts how often each word appears in a piece of text, ignoring case and punctuation. The second returns true if a phrase is a palindrome, again ignoring case, spaces and punctuation ("A man, a plan, a canal: Panama!" is one).

Solution

using System.Text;

string sample = "A man, a plan, a canal: Panama! The man said: don't panic. The plan, the plan!";

Console.WriteLine("Word frequency:");
foreach (var (word, count) in CountWords(sample).OrderByDescending(p => p.Value).ThenBy(p => p.Key))
{
    Console.WriteLine($"  {word,-8} {count}");
}

foreach (string text in new[] { "A man, a plan, a canal: Panama!", "Hello, World", "Was it a car or a cat I saw?" })
{
    Console.WriteLine($"\"{text}\" is a palindrome: {IsPalindrome(text)}");
}

static Dictionary<string, int> CountWords(string text)
{
    var counts = new Dictionary<string, int>();
    var word = new StringBuilder();

    foreach (char c in text)
    {
        if (char.IsLetterOrDigit(c) || c == '\'')
        {
            word.Append(char.ToLowerInvariant(c));
        }
        else
        {
            AddWord(counts, word);
        }
    }
    AddWord(counts, word); // the last word has no trailing separator

    return counts;
}

static void AddWord(Dictionary<string, int> counts, StringBuilder word)
{
    if (word.Length == 0) return;

    string w = word.ToString();
    counts[w] = counts.GetValueOrDefault(w) + 1;
    word.Clear();
}

static bool IsPalindrome(string text)
{
    var cleaned = new StringBuilder(text.Length);
    foreach (char c in text)
    {
        if (char.IsLetterOrDigit(c))
        {
            cleaned.Append(char.ToLowerInvariant(c));
        }
    }

    int left = 0, right = cleaned.Length - 1;
    while (left < right)
    {
        if (cleaned[left++] != cleaned[right--]) return false;
    }
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Output:

Word frequency:
  a        3
  plan     3
  the      3
  man      2
  canal    1
  don't    1
  panama   1
  panic    1
  said     1
"A man, a plan, a canal: Panama!" is a palindrome: True
"Hello, World" is a palindrome: False
"Was it a car or a cat I saw?" is a palindrome: True
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. One pass over the text, no punctuation list. Asking char.IsLetterOrDigit(c) is better than listing punctuation to remove, because it also handles characters you did not think of, including accented letters.
  2. One StringBuilder is reused for every word. Clear() empties it without allocating a new one, so there is no string concatenation inside the loop.
  3. counts[w] = counts.GetValueOrDefault(w) + 1 is the standard counting idiom: a word not seen before counts as 0, then becomes 1. No ContainsKey check, no KeyNotFoundException.
  4. The final AddWord call after the loop catches the last word, which has no separator after it. Forgetting this is the classic off-by-one bug in word counters.
  5. The palindrome check uses two pointers moving inwards and stops at the first mismatch. The shortcut of reversing the cleaned string and comparing works too, but it allocates a second string.
  6. A design choice worth knowing: the apostrophe is kept inside words so "don't" stays one word. The price is that a word wrapped in single quotes keeps its quotes. Decide that rule on purpose and write it down.

Concept 3: Control flow: if, switch, for, foreach and while

What it is. Control flow decides which code runs and how many times. if/else chooses between paths. switch chooses between many paths based on one value, and the modern switch expression returns a value. for, foreach and while repeat code.

The analogy: driving. if is a fork in the road. switch is the signboard at a big junction listing every exit. A for loop is laps round a track with a lap counter. foreach is walking down a street visiting every house. while is waiting at a red light: you stay until the condition changes. break is leaving the roundabout early and continue is skipping this house and moving to the next.

int score = 72;

// if / else if / else
if (score >= 90) Console.WriteLine("A");
else if (score >= 70) Console.WriteLine("B");
else Console.WriteLine("C");

// switch statement
DayOfWeek day = DayOfWeek.Saturday;
switch (day)
{
    case DayOfWeek.Saturday:
    case DayOfWeek.Sunday:
        Console.WriteLine("Weekend");
        break;
    default:
        Console.WriteLine("Weekday");
        break;
}

// switch expression: produces a value
string label = score switch
{
    >= 90 => "Excellent",
    >= 70 => "Good",
    _ => "Keep going"
};
Console.WriteLine(label);

// for: you need the index
for (int i = 0; i < 3; i++) Console.Write($"{i} ");
Console.WriteLine();

// foreach: you just need each item
foreach (string fruit in new[] { "apple", "banana" }) Console.Write($"{fruit} ");
Console.WriteLine();

// while: repeat until a condition changes
int n = 1;
while (n < 100) n *= 3;
Console.WriteLine(n);

// break and continue
for (int i = 1; i <= 10; i++)
{
    if (i % 2 == 0) continue;   // skip evens
    if (i > 7) break;           // stop entirely
    Console.Write($"{i} ");
}
Console.WriteLine();
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • Use for when you need the index, foreach when you just need each item, and while when you do not know in advance how many times to repeat.
  • A switch expression (score switch { >= 90 => ... }) produces a value, so it can be assigned or returned directly. The arms are checked top to bottom, so order matters. _ is the catch-all.
  • In a classic switch statement, every case must end in break (or return). Stacked cases such as Saturday: followed by Sunday: share one body.
  • foreach cannot change the collection it is walking. Adding or removing items inside it throws an InvalidOperationException.

Practice question 3: number-guessing game

Build a number-guessing game with a limited number of attempts and hints. The hint text must come from a switch expression. Invalid input must not crash the game.

Solution

const int Min = 1;
const int Max = 100;
const int MaxAttempts = 7;

int secret = Random.Shared.Next(Min, Max + 1);
int attemptsLeft = MaxAttempts;

Console.WriteLine($"I'm thinking of a number between {Min} and {Max}. You have {MaxAttempts} attempts.");

while (attemptsLeft > 0)
{
    Console.Write($"Guess ({attemptsLeft} left): ");

    if (!int.TryParse(Console.ReadLine(), out int guess) || guess < Min || guess > Max)
    {
        Console.WriteLine($"Enter a whole number from {Min} to {Max}. That did not cost an attempt.");
        continue;
    }

    attemptsLeft--;

    if (guess == secret)
    {
        Console.WriteLine($"Correct! You used {MaxAttempts - attemptsLeft} attempt(s).");
        return;
    }

    Console.WriteLine(GetHint(guess, secret));
}

Console.WriteLine($"Out of attempts. The number was {secret}.");

static string GetHint(int guess, int secret)
{
    string direction = guess < secret ? "higher" : "lower";

    return Math.Abs(guess - secret) switch
    {
        <= 5 => $"Burning hot! Go {direction}.",
        <= 15 => $"Warm. Go {direction}.",
        <= 30 => $"Cool. Go {direction}.",
        _ => $"Cold. Go {direction}."
    };
}
Enter fullscreen mode Exit fullscreen mode

Sample run (secret number 42):

I'm thinking of a number between 1 and 100. You have 7 attempts.
Guess (7 left): abc
Enter a whole number from 1 to 100. That did not cost an attempt.
Guess (7 left): 50
Warm. Go lower.
Guess (6 left): 30
Warm. Go higher.
Guess (5 left): 40
Burning hot! Go higher.
Guess (4 left): 42
Correct! You used 4 attempt(s).
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. Settings are const values at the top. Changing the range or the attempt limit is a one-line edit, and the text messages update themselves.
  2. A while loop with an attemptsLeft counter is clearer than a for loop here, because invalid input must not use up an attempt. Changing a for loop's counter inside the body is a trap.
  3. int.TryParse plus a range check in one condition, so a letter, an empty line and 0 or 101 all take the same friendly path.
  4. GetHint is a separate pure method. It takes numbers in and returns text out, with no Console calls, so you can test it without playing the game. Input/output stays in the loop, logic stays in the method.
  5. A switch expression with relational patterns (<= 5, <= 15) replaces an if/else ladder with four readable lines. The order matters: the smallest limit comes first.
  6. Random.Shared is thread-safe and needs no new Random(), which can produce repeated numbers if created in quick succession on older runtimes.

Concept 4: Methods: parameters, out, ref, optional and named arguments, params, expression-bodied members

What it is. A method is a named block of code that takes inputs (parameters) and may return a result. Writing methods is how you stop repeating yourself and how you give a piece of logic a name.

The analogy: a vending machine. You put in a coin and a selection (parameters), and it gives you back a snack (the return value). ref is handing over your original document so the clerk can edit it, rather than a photocopy. out is a machine with a second delivery slot: it hands back extra items besides the main one. params is a bag that takes any number of items. Optional parameters are the options that have a default, like "sugar: none" unless you say otherwise.

Console.WriteLine(Greet("Asha"));                              // Hello, Asha!
Console.WriteLine(Greet("Asha", greeting: "Welcome"));         // Welcome, Asha!   (named + optional)

Console.WriteLine(Sum(1, 2, 3, 4));                            // 10  (params)

int x = 1, y = 2;
Swap(ref x, ref y);                                            // ref: modifies the caller's variables
Console.WriteLine($"{x} {y}");                                 // 2 1

Console.WriteLine(Square(9));                                  // 81

static string Greet(string name, string greeting = "Hello") => $"{greeting}, {name}!";

static int Sum(params int[] numbers)
{
    int total = 0;
    foreach (int n in numbers) total += n;
    return total;
}

static void Swap(ref int a, ref int b) => (a, b) = (b, a);

static int Square(int n) => n * n;      // expression-bodied member
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • By default arguments are passed by value: the method works on a copy. ref passes the variable itself, so the method can change the caller's variable.
  • out means the method must assign that parameter before it returns. It is how int.TryParse returns both a success flag and a value.
  • Optional parameters need a default value and must come last. Named arguments (greeting: "Welcome") make a call readable and let you skip optional ones.
  • params int[] lets the caller write Sum(1, 2, 3, 4) instead of building an array.
  • => after the signature is an expression-bodied member: for one-line methods it removes the braces and the return.

Practice question 4: GetStats with out parameters, then with a tuple

Write GetStats(int[] nums) that returns the minimum, maximum and average using out parameters. Then rewrite it so it returns a tuple instead. Decide what should happen for an empty array.

Solution

int[] data = { 4, 9, -2, 15, 7 };

// Version 1: out parameters
if (TryGetStats(data, out int min, out int max, out double average))
{
    Console.WriteLine($"out version:   min={min}, max={max}, average={average:F2}");
}

// Version 2: tuple return, deconstructed at the call site
var (tMin, tMax, tAverage) = GetStats(data);
Console.WriteLine($"tuple version: min={tMin}, max={tMax}, average={tAverage:F2}");

// Named element access also works
var stats = GetStats(data);
Console.WriteLine($"range = {stats.Max - stats.Min}");

static bool TryGetStats(int[] nums, out int min, out int max, out double average)
{
    if (nums is null || nums.Length == 0)
    {
        min = max = 0;
        average = 0;
        return false;
    }

    min = max = nums[0];
    long sum = 0; // long so a big array of big ints cannot overflow
    foreach (int n in nums)
    {
        if (n < min) min = n;
        if (n > max) max = n;
        sum += n;
    }
    average = (double)sum / nums.Length;
    return true;
}

static (int Min, int Max, double Average) GetStats(int[] nums)
{
    ArgumentNullException.ThrowIfNull(nums);
    if (nums.Length == 0)
    {
        throw new ArgumentException("Array must contain at least one element.", nameof(nums));
    }

    int min = nums[0], max = nums[0];
    long sum = 0;
    foreach (int n in nums)
    {
        if (n < min) min = n;
        if (n > max) max = n;
        sum += n;
    }
    return (min, max, (double)sum / nums.Length);
}
Enter fullscreen mode Exit fullscreen mode

Output:

out version:   min=-2, max=15, average=6.60
tuple version: min=-2, max=15, average=6.60
range = 17
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. The out version follows the Try pattern (TryGetStats returns a bool), the same shape as int.TryParse. The caller is forced to deal with the empty-array case, because the if is how they get the values.
  2. The out version still assigns every out parameter on the failure path. The compiler requires it, and it keeps the values predictable.
  3. The tuple version reads better at the call site: var (min, max, avg) = GetStats(data); or stats.Max - stats.Min. Named tuple elements (Min, Max, Average) document what each value is. Prefer tuples for new code; you will still meet out in existing APIs.
  4. An empty array is handled deliberately. In the tuple version, passing an empty array is a programming error, so it throws ArgumentException. In the Try version it is a normal outcome, so it returns false. Pick one rule per method and stay consistent.
  5. ArgumentNullException.ThrowIfNull is a guard clause: reject bad input in the first line so the rest of the method can assume good data.
  6. The sum is a long, so a large array of large int values cannot overflow before the division. The result is cast with (double)sum / nums.Length to avoid integer division.
  7. One loop computes all three values. The shortcut (nums.Min(), nums.Max(), nums.Average()) is a good one-liner for small data, but it walks the array three times.

Concept 5: Arrays, List and Dictionary

What it is. Collections hold many values together. An array has a fixed size. A List<T> grows and shrinks. A Dictionary<TKey, TValue> stores key and value pairs and finds a value by its key almost instantly.

The analogy: an array is an egg carton: a fixed number of numbered slots, and you cannot add a thirteenth egg to a dozen carton. A List<T> is a shopping list on a notepad: add and cross out items as you like. A Dictionary is a coat check: you hand over a ticket (the key) and get exactly your coat (the value) back immediately, without anyone searching the whole rack.

// Array: fixed size, fastest, indexed from 0
int[] scores = new int[3] { 90, 75, 82 };
scores[1] = 80;
Console.WriteLine(scores.Length);          // 3 (cannot grow)

// List<T>: grows and shrinks
var names = new List<string> { "Asha", "Ravi" };
names.Add("Meena");
names.Remove("Ravi");
Console.WriteLine($"{names.Count}: {string.Join(", ", names)}");   // 2: Asha, Meena

// Dictionary<K,V>: look up a value by its key
var stock = new Dictionary<string, int>
{
    ["pen"] = 10,
    ["book"] = 4
};
stock["pen"] += 5;                         // update
stock["lamp"] = 2;                         // add

if (stock.TryGetValue("book", out int qty))
    Console.WriteLine($"book: {qty}");     // book: 4

Console.WriteLine(stock.ContainsKey("desk"));   // False

foreach (var (item, count) in stock)
    Console.WriteLine($"{item} = {count}");
Enter fullscreen mode Exit fullscreen mode

What is happening here:

  • Arrays are indexed from 0, and Length is fixed once created. Reading past the end throws IndexOutOfRangeException.
  • List<T> gives you Add, Remove, Count and an indexer. It is the default collection when you do not know the size up front.
  • stock["pen"] throws KeyNotFoundException if the key is missing. Use TryGetValue when the key might not be there, and ContainsKey just to check.
  • Keys in a dictionary must be unique. Assigning with stock["pen"] = ... adds or overwrites; calling Add on an existing key throws.
  • A foreach over a dictionary gives key and value pairs, which you can deconstruct as var (item, count).

Practice question 5: in-memory inventory

Build an in-memory inventory using a Dictionary. It must support adding stock, removing an item, updating a quantity, and a low-stock report.

Solution

var inventory = new Inventory();

inventory.Add("Keyboard", 12);
inventory.Add("Mouse", 3);
inventory.Add("Monitor", 2);
inventory.Add("mouse", 1);             // same item: names ignore case, so Mouse goes from 3 to 4
inventory.SetQuantity("Monitor", 1);
inventory.Remove("Keyboard");

Console.WriteLine($"Remove missing item: {inventory.Remove("Webcam")}");
Console.WriteLine("Low stock (5 or fewer):");
foreach (var (name, quantity) in inventory.GetLowStock(threshold: 5))
{
    Console.WriteLine($"  {name}: {quantity}");
}

Console.WriteLine("All items:");
foreach (var (name, quantity) in inventory.GetAll())
{
    Console.WriteLine($"  {name}: {quantity}");
}

public class Inventory
{
    private readonly Dictionary<string, int> _stock = new(StringComparer.OrdinalIgnoreCase);

    public void Add(string name, int quantity)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);

        name = name.Trim();
        _stock[name] = _stock.GetValueOrDefault(name) + quantity;
    }

    public bool Remove(string name) => _stock.Remove(name.Trim());

    public bool SetQuantity(string name, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(quantity);

        name = name.Trim();
        if (!_stock.ContainsKey(name)) return false;

        _stock[name] = quantity;
        return true;
    }

    public List<(string Name, int Quantity)> GetLowStock(int threshold = 5)
    {
        var low = new List<(string Name, int Quantity)>();
        foreach (var (name, quantity) in _stock)
        {
            if (quantity <= threshold) low.Add((name, quantity));
        }
        low.Sort((a, b) => a.Quantity.CompareTo(b.Quantity));
        return low;
    }

    public List<(string Name, int Quantity)> GetAll() =>
        _stock.OrderBy(p => p.Key).Select(p => (p.Key, p.Value)).ToList();
}
Enter fullscreen mode Exit fullscreen mode

Output:

Remove missing item: False
Low stock (5 or fewer):
  Monitor: 1
  Mouse: 4
All items:
  Monitor: 1
  Mouse: 4
Enter fullscreen mode Exit fullscreen mode

Why this is the best way to write it:

  1. A Dictionary fits because the question is always "how many of this item?". A List would scan every element on each lookup; the dictionary jumps straight to the answer.
  2. StringComparer.OrdinalIgnoreCase is set once, in the constructor. "Mouse" and "mouse" are the same item everywhere, with no scattered ToLower() calls.
  3. The dictionary is private readonly and wrapped in an Inventory class. Callers can only change stock through methods that validate the input, so a negative quantity can never get in.
  4. Add-or-top-up is one line: _stock[name] = _stock.GetValueOrDefault(name) + quantity. The same call works for a new item and for an existing one.
  5. Failure is reported in the return value. Remove and SetQuantity return a bool for the normal case "that item does not exist", while the ThrowIf... guard helpers throw only for genuinely invalid arguments such as a blank name or a negative quantity.
  6. The report returns data, not text. GetLowStock returns a list of tuples sorted by quantity and leaves printing to the caller, so the same method can feed a console, a file or a web page later.
  7. The threshold has a default value, and the call site uses a named argument (threshold: 5), pulling in concepts 3 and 4 as well.

Five classic Day 1 mistakes

Each one follows the same pattern: what people write, why it hurts, what to write instead.

  1. Integer division. double half = 7 / 2; gives 3. Both sides are int, so the division is done before the assignment. Write 7 / 2.0 or (double)7 / 2.
  2. double for money. 0.1 + 0.2 == 0.3 is False. Use decimal for prices and balances, and compare double values with a tolerance.
  3. String concatenation in a loop. result += item; copies the whole string every pass, which turns a loop into quadratic work. Use StringBuilder.
  4. int.Parse on user input. It throws a FormatException on anything that is not a number. Use int.TryParse and handle false.
  5. Indexing a dictionary blindly. stock["desk"] throws KeyNotFoundException when the key is missing. Use TryGetValue, or GetValueOrDefault when a default is fine.

Key lessons

  • Every variable has a fixed type; widening conversions are implicit, narrowing ones need an explicit cast
  • Use TryParse for anything a user types, and decimal for money
  • Strings are immutable; build them in loops with StringBuilder
  • Compare text with a StringComparison instead of lower-casing both sides
  • Pick the loop that matches the question: for for an index, foreach for each item, while for an unknown count
  • A switch expression returns a value and replaces most if/else ladders
  • Keep logic in small methods that take values in and return values out, away from Console calls
  • Prefer tuples over out for new code; use the Try pattern when failure is a normal outcome
  • List<T> for ordered sequences, Dictionary<K,V> for lookups by key
  • Wrap a collection in a class so every change goes through validation

Summary

Day 1 is the language core: the types you store data in, the strings you display it with, the control flow that decides what happens next, the methods that package it, and the collections that hold it. The five practice projects each stretch more than one idea at once, which is the point: real programs never use one concept at a time. If you can write all five from a blank file, you are ready for Day 2: classes, inheritance, interfaces, exceptions, null handling and file I/O.


More on these topics: C# posts

Top comments (0)