The refactoring is now published as Slice on sammancoaching.org: https://sammancoaching.org/refactorings/slice.html - git mv ideas/split-loop-with-lambda -> ideas/slice-with-lambda - rename learning-hour file and update frontmatter name - update paths in tools/rehearse scripts and report title - update prose: 'Split Loop with Lambda' -> 'Slice with Lambda' in learning hour, exercise READMEs, storyboard, solution comments - link the published Slice refactoring from the pattern draft
4.0 KiB
Example 3 — Buyers: extract lambda in its purest form
Third rep of the Slice with Lambda mechanics, in C#. Example 1
(longRangeForecast) had a drift trap and a dead call; the demo
(printSalesForecasts) had three dependencies and grew into a role interface.
This one has one loop and one lambda-able expression, and nothing else —
the move in its purest form. Diagrams use the same
vocabulary as the main storyboard.
Kata theme: the sellers exist (DailyForecasts — Steve, Julie, Francis), the
buyers don't. Buyers computes a buyer's shopping-list total; PriceList
stands for the external pricing service. Exercise repo: ../exercise3/csharp,
end state: ../exercise3/solution.
Step 0 — one loop, one unwanted dependency
Goal: the test is red or flaky — why? (PriceList adds random surge
pricing; it stands for the external pricing service.)
Before:
public int ShoppingListTotal(string buyerName)
{
var buyer = ByName(buyerName);
var total = 0;
foreach (var flavour in buyer.Favourites)
{
total += buyer.ScoopsOf(flavour) * priceList.PriceFor(flavour); // ← unwanted dependency
}
return total;
}
Checkpoint: run the test. Flaky. What does the loop need from outside? Given a flavour, what does it cost? That question is the lambda.
Step 1 — extract the pricing call into a lambda, and slide it
Goal: the move, with nothing else in the way. Extract, then slide — the invocation stays in the loop.
After:
public int ShoppingListTotal(string buyerName)
{
Func<IceCream, int> priceFor = flavour => priceList.PriceFor(flavour); // ← SLID to the top...
var buyer = ByName(buyerName);
var total = 0;
foreach (var flavour in buyer.Favourites)
{
total += buyer.ScoopsOf(flavour) * priceFor(flavour); // ← ...invocation stays here
}
return total;
}
Checkpoint: compiles; test still flaky (the lambda still calls
PriceList — that is fine). Unlike the scoring-loop example, priceList is a
field, so the lambda slides all the way to the top. Slide, don't move.
Step 2 — extract the loop; the lambda becomes a parameter
Goal: select the loop, Extract Method. The loop touches no fields — the lambda is the only thing crossing the boundary.
After:
public int ShoppingListTotal(string buyerName) // ← no-arg overload: callers unchanged
{
return ShoppingListTotal(ByName(buyerName), priceList.PriceFor); // ← the "real" pricing, as a method group
}
public int ShoppingListTotal(Buyer buyer, Func<IceCream, int> priceFor) // ← the seam: pricing is a parameter
{
var total = 0;
foreach (var flavour in buyer.Favourites)
{
total += buyer.ScoopsOf(flavour) * priceFor(flavour);
}
return total;
}
(The extracted loop method touches no fields; it sits in the same class as
ByName for convenience. Move it to a class without PriceList any time —
that is step 4 of the full pattern, unblocked.)
The test — the whole point:
[TestMethod]
public void ShoppingListTotalUsesThePricingWePassIn()
{
// pure pricing function: everything costs 2.
// Anke: 2 flavours x 2 scoops x 2 = 8 — deterministic, glanceable.
var buyers = new Buyers();
var anke = new Buyer("Anke", new List<IceCream> { IceCream.Strawberry, IceCream.Vanilla });
Assert.AreEqual(8, buyers.ShoppingListTotal(anke, _ => 2));
}
Checkpoint: green — deterministically. PriceList is never touched.
grep PriceFor src/— the extracted method has no hits.- C# note: the extracted method stays an instance method here because the
class also holds
ByName; if you want astatic, extract the loop only and keep the lookup in the composed method.
Debrief in one line: the loop now reads "for each favourite flavour, add scoops times price" — and the pricing is a question anyone can answer with one line of test code.