patternMajor
Return total balance from unsorted purchase items that may have specials
Viewed 0 times
totalmayreturnunsortedbalanceitemsthatspecialspurchasefrom
Problem
This is in reference to the following kata:
I am still trying to learn F#.
Thus, I have the following code:
I don't like the way I coupled the Specials DU to an item. Thus, my
Item Unit Special
Price Price
--------------------------
A 50 3 for 130
B 30 2 for 45
C 20
D 15I am still trying to learn F#.
Thus, I have the following code:
module Checkout
(*Types*)
type Type = A | B | C | D
type Total = { UnitPrice:int ; Qty:int }
member x.Price() = x.UnitPrice * x.Qty
type Special =
| None
| ThreeForOneThirty
| TwoForFourtyFive
type Item =
{ Type:Type
Total:Total
Special:Special }
member x.Price() =
match x.Special with
| ThreeForOneThirty ->
if x.Total.Qty / 3 > 0
then (x.Total.Qty / 3) * 130
else x.Total.Price()
| TwoForFourtyFive ->
if x.Total.Qty / 2 > 0
then (x.Total.Qty / 2) * 45
else x.Total.Price()
| None -> x.Total.Price()
(*Private Functions*)
let private getTypeQty itemsOfType =
itemsOfType |> Seq.sumBy(fun x -> x.Total.Qty)
let private consolidate group acc =
let first = group |> Seq.head
{ Type = first.Type;
Total = { Qty=group |> getTypeQty; UnitPrice=first.Total.UnitPrice }
Special = first.Special; } :: acc
(*Tests*)
open FsUnit
open NUnit.Framework
[]
let ``buying (2) A units, (1) B unit, (1) A unit = $160`` () =
// Setup
let a2 = { Type=A; Total={UnitPrice=50; Qty=2}; Special=ThreeForOneThirty }
let b = { Type=B; Total={UnitPrice=30; Qty=1}; Special=TwoForFourtyFive }
let a = { Type=A; Total={UnitPrice=50; Qty=1}; Special=ThreeForOneThirty }
seq [a2; b; a] |> Seq.groupBy (fun item -> item.Type)
|> Seq.map snd
|> Seq.fold(fun consolidated group -> consolidate group consolidated) []
|> Seq.sumBy (fun item -> item.Price())
|> should equal 160I don't like the way I coupled the Specials DU to an item. Thus, my
Solution
Paraphrasing Kent Beck (IIRC), I'd design my types so that concerns that change together are kept together, whereas concerns that change separately should be kept as separate as possible.
With that in mind, I'd start defining shopping basket items as simply as possible:
This also enables you to define the price table, independently of the discount rules:
This code snippet also defines a simple
I don't think modelling the product as a discriminated union (e.g.
The same type of argument goes for price rules. List prices change according to business decisions, and the same goes for discounts, but at different rates. Discriminated unions define a finite set of options, but I don't think a set of discounts ought to be a finite set of hard-coded options. You should be able to add or remove discount rules according to business decisions.
Instead, I'd be inclined to define a discount with the type
You can, for example, implement the rule about the
This looks, perhaps, a bit complicated, but it works like this:
Likewise, the discount for
This function is almost identical to
Apart from these discount rules, you'll also need a 'default' price calculation rule:
Notice how
Calculating a total price is now easy:
This function performs a left fold over
Tests
This implementation passes all the tests in the original kata formulation:
Flexibility
Is this the simplest possible implementation? Most likely not, but it's fairly flexible.
Imagine, for example, that you're being asked to implement a new 'bundle' discount where you get
```
// f:('a -> bool) -> ('a list -> 'a option * 'a list)
let tryFindFirst f =
let acc (found, others) x =
match fou
With that in mind, I'd start defining shopping basket items as simply as possible:
type Good = { SKU : string; Price : int }This also enables you to define the price table, independently of the discount rules:
let prices = [
{ SKU = "A"; Price = 50 }
{ SKU = "B"; Price = 30 }
{ SKU = "C"; Price = 20 }
{ SKU = "D"; Price = 15 } ]
// string -> Good option
let scan sku = prices |> List.tryFind (fun x -> x.SKU = sku)This code snippet also defines a simple
scan function that can be used to look up a string in the price table in order to turn it into a Good value.I don't think modelling the product as a discriminated union (e.g.
A | B | C | D) is a good idea, because that's going to make it hard if you want to add an E product, or if you want to remove the C product.The same type of argument goes for price rules. List prices change according to business decisions, and the same goes for discounts, but at different rates. Discriminated unions define a finite set of options, but I don't think a set of discounts ought to be a finite set of hard-coded options. You should be able to add or remove discount rules according to business decisions.
Instead, I'd be inclined to define a discount with the type
Good list -> int * Good list. The idea here is that you input a list of Good values, and you get back a price (int) and the remaining Good values where a price has yet to be calculated.You can, for example, implement the rule about the
A products like this:// Good list -> int * Good list
let aRule items =
let xs, others = List.partition (fun x -> x.SKU = "A") items
let hits, rest =
xs |> List.chunkBySize 3 |> List.partition (fun l -> l.Length = 3)
let xTotal = hits.Length * 130
xTotal, rest |> List.concat |> List.append othersThis looks, perhaps, a bit complicated, but it works like this:
- First, it partitions all items into
Aitems (xs) and all other items (others).
- It then divides
xsinto chunks of 3. This produces a list of lists, where most of the lists have the length 3. There may, however, be a residual list with one or two items, so these are partitioned into therestvalue.
- The total for all the
hitsis calculated. Each hit is a list of threeAitems, which has the special discount price130. You may have three, six, nine, etc.Aitems, so the special price130is multiplied with the length ofhits, which is a list of lists.
- Finally,
xTotalis returned, together with all the items that didn't trigger the discount. These include all non-Aitems, but also residualAitems.
Likewise, the discount for
B items can be defined in the same way:// Good list -> int * Good list
let bRule items =
let xs, others = List.partition (fun x -> x.SKU = "B") items
let hits, rest =
xs |> List.chunkBySize 2 |> List.partition (fun l -> l.Length = 2)
let xTotal = hits.Length * 45
xTotal, rest |> List.concat |> List.append othersThis function is almost identical to
aRule, so is a candidate for some refactoring. Here, however, I decided to apply the rule of three, so I left them as is.Apart from these discount rules, you'll also need a 'default' price calculation rule:
// Good list -> int * 'a list
let defaultRule items = List.sumBy (fun x -> x.Price) items, []Notice how
Good list -> int 'a list also fits the desired type of Good list -> int Good list. This enables you to define a set of rules as a list:// (Good list -> int * Good list) list
let rules = [aRule; bRule; defaultRule]Calculating a total price is now easy:
// ('a -> int * 'a) list -> 'a -> int
let total rules items =
let acc (tot, rest) f =
let tot', rest' = f rest
tot + tot', rest'
rules |> List.fold acc (0, items) |> fstThis function performs a left fold over
rules, while accumulating the total so far, and the rest of the items.Tests
This implementation passes all the tests in the original kata formulation:
open Xunit
open Swensen.Unquote
[]
[]
[]
[]
[]
[]
[]
[]
[]
[]
[]
[]
[]
[]
// Incremental
[]
[]
[]
[]
[]
let ``total returns correct result`` (items : string) expected =
let actual =
items |> Seq.choose (string >> scan) |> Seq.toList |> total rules
expected =! actualFlexibility
Is this the simplest possible implementation? Most likely not, but it's fairly flexible.
Imagine, for example, that you're being asked to implement a new 'bundle' discount where you get
A, B, and C for 85 if you buy them together. You can implement this without changing any of the existing code:```
// f:('a -> bool) -> ('a list -> 'a option * 'a list)
let tryFindFirst f =
let acc (found, others) x =
match fou
Code Snippets
type Good = { SKU : string; Price : int }let prices = [
{ SKU = "A"; Price = 50 }
{ SKU = "B"; Price = 30 }
{ SKU = "C"; Price = 20 }
{ SKU = "D"; Price = 15 } ]
// string -> Good option
let scan sku = prices |> List.tryFind (fun x -> x.SKU = sku)// Good list -> int * Good list
let aRule items =
let xs, others = List.partition (fun x -> x.SKU = "A") items
let hits, rest =
xs |> List.chunkBySize 3 |> List.partition (fun l -> l.Length = 3)
let xTotal = hits.Length * 130
xTotal, rest |> List.concat |> List.append others// Good list -> int * Good list
let bRule items =
let xs, others = List.partition (fun x -> x.SKU = "B") items
let hits, rest =
xs |> List.chunkBySize 2 |> List.partition (fun l -> l.Length = 2)
let xTotal = hits.Length * 45
xTotal, rest |> List.concat |> List.append others// Good list -> int * 'a list
let defaultRule items = List.sumBy (fun x -> x.Price) items, []Context
StackExchange Code Review Q#139578, answer score: 23
Revisions (0)
No revisions yet.