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);}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);}In the cart: 4First: Coffee beansAny 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, andList<Product>is a list of products. The type in the brackets is called a type parameter. That is why we writeList<T>, whereTis "whatever type you choose". Countis the number of elements (for an array it isLength). The index works as with an array:cart[0]is the first one.Removetakes the element out if it is there, andContainstells you whether it is there.foreachgoes 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, andClear()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);5, 2, 30string.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,
Listmakes a new array twice the size and copies the elements over. That is whyAddis usually fast, and you do not have to think about the size. - If you use an index outside the list,
cart[10], the exception isArgumentOutOfRangeException, notIndexOutOfRangeExceptionas 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]);Remove(2) removes only the first 2. That leaves { 5, 2, 1 }, and then Add(3) makes it { 5, 2, 1, 3 }. Four elements: the first is 5, the fourth is 3.
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");}Coffee in stock: 10Any X-999? FalseTea in stock: 30No product with code X-999C-101: 10 pcsT-205: 30 pcsDictionary<string, int>: the keys are strings and the values are integers.stock["C-101"] = 12writes. 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 withtry/catch, as in the "Errors and exceptions" module. It is better not to get that far:ContainsKeytells you whether the key is there, andTryGetValuechecks and reads in one go. The value comes back throughout, as withint.TryParse. foreachgoes through the pairs. Each one is aKeyValuePairwithKeyandValue.
More precisely, if you are curious
Add(key, value)also writes, but it throwsArgumentExceptionif 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);C-101 already has a price: €18.90Products 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-101code. - The order in which
foreachgives 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 key "C-102" is not in the dictionary, so prices[code] throws KeyNotFoundException. When the code comes from outside (from the customer, from a file), check it: if (prices.TryGetValue(code, out decimal price)), and in the else print "No price for C-102".
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));75, 130, 58Tomorrow 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));True FalseTrueSee 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
- Five prices. The query only sets up the path: nothing has gone through yet.
- 12 does not pass p >= 50 and drops out. It never gets to Select.
- 75 passes Where, Select turns it into the text "€75", ToList() collects it.
- 40 drops out too. Each element goes the whole way before the next one starts.
- 130 and 58 pass. The list prices does not change.
- 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 givestruefor. That is the loop with theiffrom the previous card.Select(p => "€" + p)turns each element into something else, here into a string.ToList()collects the result into a newList. Thepriceslist 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);}Coffees: 2Coffee grinder: €64.00Colombia beans: €31.50Espresso beans: €24.90Sencha green tea: €9.80Peppermint: €4.20Where(p => p.Category == "coffee")keeps only the coffees.OrderByDescending(p => p.Price)sorts by price, from the highest.OrderBysorts 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]);Three products are under €30: the espresso (€24.90), the sencha (€9.80) and the peppermint (€4.20). OrderBy sorts them from the cheapest, so the peppermint comes first.
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);Total: €134.40Average: €26.88Under €5: 1Any equipment? TrueFirst tea: Sencha green teaNo cocoaSumandAverageadd up and average what the lambda gives.Count(rule)counts the ones that match, andAny(rule)tells you whether there is at least one.First(rule)returns the first one that matches, and if there is none, it throwsInvalidOperationException.FirstOrDefaultreturnsnullin the same case. Use it when having no result is normal, and check fornull.
Which method gives the total value of the catalog?
decimal total = catalog.(p => p.Price);Sum(p => p.Price) adds up the prices of all the products: €134.40.
More precisely, if you are curious
GroupBysplits the elements into groups by a key. Each group has aKeyand acts like a list, soCountandSumwork on it. The group's type is long (IGrouping<string, Product>), sovaris handy here:
foreach (var group in catalog.GroupBy(p => p.Category)){ Console.WriteLine(group.Key + ": count " + group.Count() + ", total €" + group.Sum(p => p.Price));}coffee: count 2, total €56.40tea: count 2, total €14.00equipment: 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,OrderByandSelect:
List<string> teas = (from p in catalog where p.Category == "tea" orderby p.Price select p.Name).ToList();Console.WriteLine(string.Join(", ", teas));Peppermint, Sencha green tea- Deferred execution.
WhereandSelectdo not go through anything until someone asks for the result:ToList,foreach,Sum,string.Join. Until then the query is only a recipe, of typeIEnumerable<int>("something you can go through"). That is why 130 ends up in the result here, even though it was added afterWhere:
List<int> prices = new List<int> { 12, 75 };IEnumerable<int> expensive = prices.Where(p => p >= 50);prices.Add(130);Console.WriteLine(string.Join(", ", expensive));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,doublegives rounding errors.Sumkeeps 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:
Products: 3, total €93.10Removed: Coffee grinderProducts: 2, total €29.102. 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:
C-101: sold 5, 7 leftG-300: only 2 leftNo product X-999C-101: 7T-205: 30G-300: 23. 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"):
Top three by price: Coffee grinder €64.00 Colombia beans €31.50 Espresso beans €24.90By category: coffee: €56.40 tea: €14.00 equipment: €64.00Test