Skip to content
wannacode

Lists, dictionaries and LINQ

Problem

The cart keeps growing

A customer of the online shop puts coffee, tea and creamer in the cart, then coffee filters too. Then they take out the creamer. You cannot know in advance how many products they will buy. An array, though, wants its size right at new:

string[] cart = new string[3];
cart[0] = "Coffee beans";
cart[1] = "Green tea";
cart[2] = "Coffee creamer";
try
{
cart[3] = "Coffee filters";
}
catch (IndexOutOfRangeException e)
{
Console.WriteLine("It does not fit: " + e.Message);
}
Output
It does not fit: Index was outside the bounds of the array.

There is a second problem. The stock keeps products by code, like C-101. To find the quantity for one code in an array, you loop over the array every time. With 10,000 products and 100 orders a minute, that is a lot of looping for nothing.

See it

List<T>: an array that grows

A list (List) keeps elements in order, like an array, but it grows with Add and shrinks with Remove:

List<string> cart = new List<string>();
cart.Add("Coffee beans");
cart.Add("Green tea");
cart.Add("Coffee creamer");
cart.Add("Coffee filters");
Console.WriteLine("In the cart: " + cart.Count);
Console.WriteLine("First: " + cart[0]);
cart.Remove("Coffee creamer");
Console.WriteLine("Any creamer? " + cart.Contains("Coffee creamer"));
foreach (string item in cart)
{
Console.WriteLine("- " + item);
}
Output
In the cart: 4
First: Coffee beans
Any creamer? False
- Coffee beans
- Green tea
- Coffee filters
  • In List<string>, the angle brackets say what is in the list: strings. List<int> is a list of integers, and List<Product> is a list of products. The type in the brackets is called a type parameter. That is why we write List<T>, where T is "whatever type you choose".
  • Count is the number of elements (for an array it is Length). The index works as with an array: cart[0] is the first one.
  • Remove takes the element out if it is there, and Contains tells you whether it is there. foreach goes through all of them.

In this module the programs have no class Program and Main wrapper: the lines sit directly in the file. These are the top-level statements from the "Getting your computer ready" lesson: the compiler puts Main around them for you. Classes, when there are any, come after these lines.

More precisely, if you are curious
  • You can fill a list right when you create it: new List<int> { 2, 1, 3 }. There are more methods too: Insert(index, value) puts a value at a position, RemoveAt(index) removes by position, and Clear() empties the list.
List<int> quantities = new List<int> { 2, 1, 3 };
quantities.Insert(0, 5);
quantities.RemoveAt(2);
Console.WriteLine(string.Join(", ", quantities));
quantities.Clear();
Console.WriteLine(quantities.Count);
Output
5, 2, 3
0
  • string.Join(", ", list) glues the elements into one string with a separator between them.
  • Inside, a list is an array with some spare room. When it fills up, List makes a new array twice the size and copies the elements over. That is why Add is usually fast, and you do not have to think about the size.
  • If you use an index outside the list, cart[10], the exception is ArgumentOutOfRangeException, not IndexOutOfRangeException as with an array.

Try it

What is left in the list

What will the program print?

List<int> quantities = new List<int> { 2, 5, 2, 1 };
quantities.Remove(2);
quantities.Add(3);
Console.WriteLine(quantities.Count + " " + quantities[0] + " " + quantities[3]);
Answer

See it

A dictionary: lookup by code

A dictionary (Dictionary) keeps pairs of key → value. Here the key is the product code, and the value is the quantity in stock. Finding a key is almost instant, with no loop, however many products there are:

Dictionary<string, int> stock = new Dictionary<string, int>();
stock["C-101"] = 12;
stock["T-205"] = 30;
stock["C-101"] = stock["C-101"] - 2;
Console.WriteLine("Coffee in stock: " + stock["C-101"]);
Console.WriteLine("Any X-999? " + stock.ContainsKey("X-999"));
if (stock.TryGetValue("T-205", out int tea))
{
Console.WriteLine("Tea in stock: " + tea);
}
try
{
Console.WriteLine(stock["X-999"]);
}
catch (KeyNotFoundException)
{
Console.WriteLine("No product with code X-999");
}
foreach (KeyValuePair<string, int> pair in stock)
{
Console.WriteLine(pair.Key + ": " + pair.Value + " pcs");
}
Output
Coffee in stock: 10
Any X-999? False
Tea in stock: 30
No product with code X-999
C-101: 10 pcs
T-205: 30 pcs
  • Dictionary<string, int>: the keys are strings and the values are integers.
  • stock["C-101"] = 12 writes. If the key is already there, the value is replaced. stock["C-101"] reads.
  • Reading a key that is not there throws KeyNotFoundException. You catch it with try/catch, as in the "Errors and exceptions" module. It is better not to get that far: ContainsKey tells you whether the key is there, and TryGetValue checks and reads in one go. The value comes back through out, as with int.TryParse.
  • foreach goes through the pairs. Each one is a KeyValuePair with Key and Value.
More precisely, if you are curious
  • Add(key, value) also writes, but it throws ArgumentException if the key is already there. That way you do not overwrite a price by mistake. You can fill a dictionary when you create it, with pairs in curly braces:
Dictionary<string, decimal> prices = new Dictionary<string, decimal>
{
{ "C-101", 18.90m },
{ "T-205", 6.40m },
};
try
{
prices.Add("C-101", 19.50m);
}
catch (ArgumentException)
{
Console.WriteLine("C-101 already has a price: €" + prices["C-101"]);
}
prices.Remove("T-205");
Console.WriteLine("Products with a price: " + prices.Count);
Output
C-101 already has a price: €18.90
Products with a price: 1
  • Keys are unique, values are not. Two products can both have a quantity of 30, but there is only one C-101 code.
  • The order in which foreach gives you the pairs is not promised. Today it is the order you added them in, if you have not removed anything, but do not rely on it. If you need an order, sort the keys.
  • A dictionary finds a key fast because it computes a number from it (a hash) and uses it to know where to look. That is why a key must not change while it is in the dictionary.

Try it

A code that is not there

The program should show the price of the product with code C-102. Instead, it stops with an exception. Which line is the problem on?

Pick the line with the bug.

The idea

A rule as a value: the lambda

Finding the prices of €50 and up is a loop with an if:

List<int> prices = new List<int> { 12, 75, 40, 130, 58 };
List<int> expensive = new List<int>();
foreach (int p in prices)
{
if (p >= 50)
{
expensive.Add(p);
}
}
Console.WriteLine(string.Join(", ", expensive));
Output
75, 130, 58

Tomorrow you will need the products under €10, then only the teas. The loop is the same; only the condition in the if changes. It would be handy to pass the condition as a value to a method that does the loop for you.

A condition like that is written as a lambda: p => p >= 50. Read it as "for each p, give p >= 50". Left of => is the parameter, right of it is the result. A lambda is a short method with no name, no return and no types: C# works them out by itself.

More precisely, if you are curious

A lambda has a type, like every value. Func<int, bool> is "a method that takes an int and returns a bool". You can store it in a variable and call it like a method. A lambda can also have a body in curly braces with a return, when one expression is not enough:

Func<int, bool> expensive = p => p >= 50;
Console.WriteLine(expensive(75) + " " + expensive(12));
Func<decimal, bool> overWithVat = p =>
{
decimal withVat = p * 1.2m;
return withVat > 50;
};
Console.WriteLine(overWithVat(45.00m));
Output
True False
True

See it

Where and Select

List<int> prices = new List<int> { 12, 75, 40, 130, 58 };
List<string> labels = prices
.Where(p => p >= 50)
.Select(p => "€" + p)
.ToList();
Console.WriteLine(string.Join(", ", labels));
Step by step, in words
  1. Five prices. The query only sets up the path: nothing has gone through yet.
  2. 12 does not pass p >= 50 and drops out. It never gets to Select.
  3. 75 passes Where, Select turns it into the text "€75", ToList() collects it.
  4. 40 drops out too. Each element goes the whole way before the next one starts.
  5. 130 and 58 pass. The list prices does not change.
  6. The result is a new list with three strings.

The Where and Select methods come from LINQ (Language Integrated Query): queries over data, built into the language. They work on any list and array:

  • Where(p => p >= 50) lets through only the elements the lambda gives true for. That is the loop with the if from the previous card.
  • Select(p => "€" + p) turns each element into something else, here into a string.
  • ToList() collects the result into a new List. The prices list does not change.

The elements go through one at a time: 12 drops out at Where and never reaches Select.

See it

The shop's products

A real catalog is a list of objects. The Product class has only properties, so the objects are created with the properties in curly braces: new Product { Id = 1, Name = "…" }. This is the same as new Product() followed by p.Id = 1; for each property.

List<Product> catalog = new List<Product>
{
new Product { Id = 1, Name = "Espresso beans", Price = 24.90m, Category = "coffee" },
new Product { Id = 2, Name = "Sencha green tea", Price = 9.80m, Category = "tea" },
new Product { Id = 3, Name = "Colombia beans", Price = 31.50m, Category = "coffee" },
new Product { Id = 4, Name = "Peppermint", Price = 4.20m, Category = "tea" },
new Product { Id = 5, Name = "Coffee grinder", Price = 64.00m, Category = "equipment" },
};
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
}

The lambda gets a whole product, so the condition reads its properties:

List<Product> coffee = catalog.Where(p => p.Category == "coffee").ToList();
Console.WriteLine("Coffees: " + coffee.Count);
List<string> byPrice = catalog
.OrderByDescending(p => p.Price)
.Select(p => p.Name + ": €" + p.Price)
.ToList();
foreach (string line in byPrice)
{
Console.WriteLine(line);
}
Output
Coffees: 2
Coffee grinder: €64.00
Colombia beans: €31.50
Espresso beans: €24.90
Sencha green tea: €9.80
Peppermint: €4.20
  • Where(p => p.Category == "coffee") keeps only the coffees.
  • OrderByDescending(p => p.Price) sorts by price, from the highest. OrderBy sorts from the lowest. The lambda says what to sort by.
  • The methods are chained one after another: each one gets the result of the previous one.

The catalog is the same. What will it print?

List<string> names = catalog
.Where(p => p.Price < 30)
.OrderBy(p => p.Price)
.Select(p => p.Name)
.ToList();
Console.WriteLine(names.Count + ": " + names[0]);
Answer

See it

One answer from the whole list

Some LINQ methods do not return a list but a single value: a sum, a count, "is there any", one product:

Console.WriteLine("Total: €" + catalog.Sum(p => p.Price));
Console.WriteLine("Average: €" + catalog.Average(p => p.Price));
Console.WriteLine("Under €5: " + catalog.Count(p => p.Price < 5));
Console.WriteLine("Any equipment? " + catalog.Any(p => p.Category == "equipment"));
Product tea = catalog.First(p => p.Category == "tea");
Console.WriteLine("First tea: " + tea.Name);
Product cocoa = catalog.FirstOrDefault(p => p.Category == "cocoa");
Console.WriteLine(cocoa == null ? "No cocoa" : cocoa.Name);
Output
Total: €134.40
Average: €26.88
Under €5: 1
Any equipment? True
First tea: Sencha green tea
No cocoa
  • Sum and Average add up and average what the lambda gives.
  • Count(rule) counts the ones that match, and Any(rule) tells you whether there is at least one.
  • First(rule) returns the first one that matches, and if there is none, it throws InvalidOperationException. FirstOrDefault returns null in the same case. Use it when having no result is normal, and check for null.

Which method gives the total value of the catalog?

decimal total = catalog.(p => p.Price);
More precisely, if you are curious
  • GroupBy splits the elements into groups by a key. Each group has a Key and acts like a list, so Count and Sum work on it. The group's type is long (IGrouping<string, Product>), so var is handy here:
foreach (var group in catalog.GroupBy(p => p.Category))
{
Console.WriteLine(group.Key + ": count " + group.Count() + ", total €" + group.Sum(p => p.Price));
}
Output
coffee: count 2, total €56.40
tea: count 2, total €14.00
equipment: count 1, total €64.00
  • The same query can also be written in query syntax, which looks like SQL. The compiler turns it into Where, OrderBy and Select:
List<string> teas = (from p in catalog
where p.Category == "tea"
orderby p.Price
select p.Name).ToList();
Console.WriteLine(string.Join(", ", teas));
Output
Peppermint, Sencha green tea
  • Deferred execution. Where and Select do not go through anything until someone asks for the result: ToList, foreach, Sum, string.Join. Until then the query is only a recipe, of type IEnumerable<int> ("something you can go through"). That is why 130 ends up in the result here, even though it was added after Where:
List<int> prices = new List<int> { 12, 75 };
IEnumerable<int> expensive = prices.Where(p => p >= 50);
prices.Add(130);
Console.WriteLine(string.Join(", ", expensive));
Output
75, 130
  • If you need the result as it is at a given moment, call ToList() right away: then the list is ready, and later changes do not affect it.
  • The prices here are decimal: with money, double gives rounding errors. Sum keeps the two digits after the decimal point: 134.40. The numbers print with a decimal point because the code check runs without regional settings. On a computer set up for a language that writes a decimal comma, such as German, you would see 134,40.

Exercise

Cart, stock and report

Solve these in your editor. Use the Product class from the lesson.

1. Cart. Put espresso beans (€24.90), peppermint (€4.20) and a coffee grinder (€64.00) in a List<Product>. Print the count and the total. Then find the grinder by Id with First, remove it and print again:

Output
Products: 3, total €93.10
Removed: Coffee grinder
Products: 2, total €29.10

2. Stock. A dictionary of code → quantity: C-101 12 pcs, T-205 30 pcs, G-300 2 pcs. Write a method Sell(stock, code, quantity) that lowers the quantity and returns a message about the result. If the code is not there or there is not enough, the dictionary does not change. Sell 5 of C-101, 3 of G-300 and 1 of X-999, then print the stock:

Output
C-101: sold 5, 7 left
G-300: only 2 left
No product X-999
C-101: 7
T-205: 30
G-300: 2

3. Report. With the catalog from the lesson, print the three most expensive products and the sum of the prices per category. Take(3) takes the first three elements. For the sums, use a dictionary of category → sum (or GroupBy from "More precisely"):

Output
Top three by price:
Coffee grinder €64.00
Colombia beans €31.50
Espresso beans €24.90
By category:
coffee: €56.40
tea: €14.00
equipment: €64.00

Test

Check yourself

1. Why is List<Product> handier than a Product[] array for a shopping cart?
2. stock is a Dictionary<string, int> without the key "X-999". What does stock["X-999"] do?
3. What does p => p.Price > 50 mean?
4. The catalog has no product in the "cocoa" category. What does catalog.FirstOrDefault(p => p.Category == "cocoa") return?
5. What is the difference between Count and Count(p => p.Price < 5) on a list?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·