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");
}
What is happening here:
-
int,double,decimal,bool,charandstringare the everyday types. Usedecimalfor money, becausedoublestores binary fractions and cannot represent 0.1 exactly (the0.1 + 0.2 == 0.3line prints False). -
7 / 2is3, not3.5, because both sides areint. Cast one side todoublefirst. -
(int)dis an explicit cast: it truncates, it does not round. -
TryParsereturns true or false and hands the result back through anoutvariable. 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 }
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
Why this is the best way to write it:
- 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.
-
One validation rule covers every scale. Checking
kelvin < 0after converting means you never write separate minimum values (-273.15, -459.67, 0) for each scale. -
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. -
TryParsewithCultureInfo.InvariantCulturemeans36.6works on every machine, even ones whose regional settings use a comma as the decimal separator. Bad input takes thecontinuepath instead of an exception. -
An
enumfor the scale means the compiler catches a typo likeScale.Kelvn, which a string comparison never would. The switch expressions throw in the_arm so a future new enum member cannot be silently ignored. -
TryParseScaleis its own method with anoutparameter, the same shape asint.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
What is happening here:
-
s.ToUpper()returns a new string and leavessalone. 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. Avoida.ToLower() == b.ToLower(), which allocates two throwaway strings. - Use
StringBuilderwhen 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;
}
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
Why this is the best way to write it:
-
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. -
One
StringBuilderis reused for every word.Clear()empties it without allocating a new one, so there is no string concatenation inside the loop. -
counts[w] = counts.GetValueOrDefault(w) + 1is the standard counting idiom: a word not seen before counts as 0, then becomes 1. NoContainsKeycheck, noKeyNotFoundException. -
The final
AddWordcall 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. - 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.
- 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();
What is happening here:
- Use
forwhen you need the index,foreachwhen you just need each item, andwhilewhen 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(orreturn). Stacked cases such asSaturday:followed bySunday:share one body. -
foreachcannot change the collection it is walking. Adding or removing items inside it throws anInvalidOperationException.
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}."
};
}
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).
Why this is the best way to write it:
-
Settings are
constvalues at the top. Changing the range or the attempt limit is a one-line edit, and the text messages update themselves. -
A
whileloop with anattemptsLeftcounter is clearer than aforloop here, because invalid input must not use up an attempt. Changing aforloop's counter inside the body is a trap. -
int.TryParseplus a range check in one condition, so a letter, an empty line and0or101all take the same friendly path. -
GetHintis a separate pure method. It takes numbers in and returns text out, with noConsolecalls, so you can test it without playing the game. Input/output stays in the loop, logic stays in the method. -
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. -
Random.Sharedis thread-safe and needs nonew 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
What is happening here:
- By default arguments are passed by value: the method works on a copy.
refpasses the variable itself, so the method can change the caller's variable. -
outmeans the method must assign that parameter before it returns. It is howint.TryParsereturns 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 writeSum(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 thereturn.
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);
}
Output:
out version: min=-2, max=15, average=6.60
tuple version: min=-2, max=15, average=6.60
range = 17
Why this is the best way to write it:
-
The
outversion follows theTrypattern (TryGetStatsreturns a bool), the same shape asint.TryParse. The caller is forced to deal with the empty-array case, because theifis how they get the values. -
The
outversion still assigns everyoutparameter on the failure path. The compiler requires it, and it keeps the values predictable. -
The tuple version reads better at the call site:
var (min, max, avg) = GetStats(data);orstats.Max - stats.Min. Named tuple elements (Min,Max,Average) document what each value is. Prefer tuples for new code; you will still meetoutin existing APIs. -
An empty array is handled deliberately. In the tuple version, passing an empty array is a programming error, so it throws
ArgumentException. In theTryversion it is a normal outcome, so it returns false. Pick one rule per method and stay consistent. -
ArgumentNullException.ThrowIfNullis a guard clause: reject bad input in the first line so the rest of the method can assume good data. -
The sum is a
long, so a large array of largeintvalues cannot overflow before the division. The result is cast with(double)sum / nums.Lengthto avoid integer division. -
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}");
What is happening here:
- Arrays are indexed from 0, and
Lengthis fixed once created. Reading past the end throwsIndexOutOfRangeException. -
List<T>gives youAdd,Remove,Countand an indexer. It is the default collection when you do not know the size up front. -
stock["pen"]throwsKeyNotFoundExceptionif the key is missing. UseTryGetValuewhen the key might not be there, andContainsKeyjust to check. - Keys in a dictionary must be unique. Assigning with
stock["pen"] = ...adds or overwrites; callingAddon an existing key throws. - A
foreachover a dictionary gives key and value pairs, which you can deconstruct asvar (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();
}
Output:
Remove missing item: False
Low stock (5 or fewer):
Monitor: 1
Mouse: 4
All items:
Monitor: 1
Mouse: 4
Why this is the best way to write it:
-
A
Dictionaryfits because the question is always "how many of this item?". AListwould scan every element on each lookup; the dictionary jumps straight to the answer. -
StringComparer.OrdinalIgnoreCaseis set once, in the constructor. "Mouse" and "mouse" are the same item everywhere, with no scatteredToLower()calls. -
The dictionary is
private readonlyand wrapped in anInventoryclass. Callers can only change stock through methods that validate the input, so a negative quantity can never get in. -
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. -
Failure is reported in the return value.
RemoveandSetQuantityreturn a bool for the normal case "that item does not exist", while theThrowIf...guard helpers throw only for genuinely invalid arguments such as a blank name or a negative quantity. -
The report returns data, not text.
GetLowStockreturns 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. -
The
thresholdhas 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.
-
Integer division.
double half = 7 / 2;gives 3. Both sides areint, so the division is done before the assignment. Write7 / 2.0or(double)7 / 2. -
doublefor money.0.1 + 0.2 == 0.3is False. Usedecimalfor prices and balances, and comparedoublevalues with a tolerance. -
String concatenation in a loop.
result += item;copies the whole string every pass, which turns a loop into quadratic work. UseStringBuilder. -
int.Parseon user input. It throws aFormatExceptionon anything that is not a number. Useint.TryParseand handle false. -
Indexing a dictionary blindly.
stock["desk"]throwsKeyNotFoundExceptionwhen the key is missing. UseTryGetValue, orGetValueOrDefaultwhen a default is fine.
Key lessons
- Every variable has a fixed type; widening conversions are implicit, narrowing ones need an explicit cast
- Use
TryParsefor anything a user types, anddecimalfor money - Strings are immutable; build them in loops with
StringBuilder - Compare text with a
StringComparisoninstead of lower-casing both sides - Pick the loop that matches the question:
forfor an index,foreachfor each item,whilefor 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
Consolecalls - Prefer tuples over
outfor new code; use theTrypattern 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)