Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Folding Cheat Sheet #4

Folding Cheat Sheetย #4

For functions that can be defined both as an instance of a right fold and as an instance of a left fold, one may be more efficient than the other.

Let's look at the example of a function 'decimal' that converts a list of digits into the corresponding decimal number.

Keywords: folding, list, right fold, left fold, recursion, tail recursion, universal property of fold, Hornerโ€™s rule.

Avatar for Philip Schwarz

Philip Schwarz

April 21, 2024
Tweet

More Decks by Philip Schwarz

Other Decks in Programming

Transcript

  1. CHEAT-SHEET Folding #4 โˆถ / \ ๐’‚๐ŸŽ โˆถ / \

    ๐’‚๐Ÿ โˆถ / \ ๐’‚๐Ÿ โˆถ / \ ๐’‚๐Ÿ‘ ๐’‡ / \ ๐’‚๐ŸŽ ๐’‡ / \ ๐’‚๐Ÿ ๐’‡ / \ ๐’‚๐Ÿ ๐’‡ / \ ๐’‚๐Ÿ‘ ๐’† @philip_schwarz slides by https://fpilluminated.com/
  2. We want to write function ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’, which given the digits

    of an integer number [๐‘‘0 , ๐‘‘1 , โ€ฆ , ๐‘‘๐‘› ] computes the integer value of the number - &'( ) ๐‘‘๐‘˜ โˆ— 10)*& Thanks to the universal property of fold, if we are able to define ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ so that its equations match those on the left hand side of the following equivalence, then we are also able to implement ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ using a right fold i.e. given ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ :: ๐›ผ โ†’ ๐›ฝ โ†’ ๐›ฝ โ†’ ๐›ฝ โ†’ ๐›ผ โ†’ ๐›ฝ ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ ๐‘“ ๐‘ฃ = ๐‘ฃ ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ ๐‘“ ๐‘ฃ ๐‘ฅ โˆถ ๐‘ฅ๐‘  = ๐‘“ ๐‘ฅ ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ ๐‘“ ๐‘ฃ ๐‘ฅ๐‘  we can reimplement ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ like this: ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ = ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ ๐‘“ ๐‘ฃ The universal property of ๐’‡๐’๐’๐’… ๐’ˆ = ๐’— โŸบ ๐’ˆ = ๐’‡๐’๐’๐’… ๐’‡ ๐’— ๐’ˆ :: ๐›ผ โ†’ ๐›ฝ ๐’ˆ ๐‘ฅ โˆถ ๐‘ฅ๐‘  = ๐’‡ ๐‘ฅ ๐’ˆ ๐‘ฅ๐‘  ๐’— :: ๐›ฝ ๐’‡ :: ๐›ผ โ†’ ๐›ฝ โ†’ ๐›ฝ scala> decimal(List(1,2,3,4)) val res0: Int = 1234 haskell> decimal [1,2,3,4] 1234 ๐‘‘0 โˆ— 103 + ๐‘‘1 โˆ— 102 + ๐‘‘2 โˆ— 101 + ๐‘‘3 โˆ— 100 = 1 โˆ— 1000 + 2 โˆ— 100 + 3 โˆ— 10 + 4 โˆ— 1 = 1234
  3. Notice that ๐’‡ has two parameters: the head of the

    list, and the result of recursively calling ๐’ˆ with the tail of the list ๐’ˆ ๐‘ฅ โˆถ ๐‘ฅ๐‘  = ๐’‡ ๐‘ฅ ๐’ˆ ๐‘ฅ๐‘  In order to define our ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ function however, the two parameters of ๐’‡ are not sufficient. When ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ is passed [๐‘‘๐‘˜, โ€ฆ , ๐‘‘๐‘›], ๐’‡ is passed digit ๐‘‘๐‘˜ , so ๐’‡ needs ๐‘› and ๐‘˜ in order to compute 10)*&, but ๐‘› โˆ’ ๐‘˜ is the number of elements in [๐‘‘๐‘˜, โ€ฆ , ๐‘‘๐‘›] minus one, so by nesting the definition of ๐’‡ inside that of ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’, we can avoid explicitly adding a third parameter to ๐’‡ : We nested ๐’‡ inside ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’, so that the equations of ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ match (almost) those of ๐’ˆ. They donโ€™t match perfectly, in that the ๐’‡ nested inside ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ depends on ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’โ€™s list parameter, whereas the ๐’‡ nested inside ๐’ˆ does not depend on ๐’ˆโ€™s list parameter. Are we still able to redefine ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ using ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ? If the match had been perfect, we would be able to define ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ = ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ ๐‘“ 0 (with ๐’— = 0), but because ๐’‡ needs to know the value of ๐‘› โˆ’ ๐‘˜, we canโ€™t just pass ๐’‡ to ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ, and use 0 as the initial accumulator. Instead, we need to use (0, 0) as the accumulator (the second 0 being the initial value of ๐‘› โˆ’ ๐‘˜, when ๐‘˜ = ๐‘›), and pass to ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ a helper function โ„Ž that manages ๐‘› โˆ’ ๐‘˜ and that wraps ๐’‡, so that the latter has access to ๐‘› โˆ’ ๐‘˜. def h(d: Int, acc: (Int,Int)): (Int,Int) = acc match { case (ds, e) => def f(d: Int, ds: Int): Int = d * Math.pow(10, e).toInt + ds (f(d, ds), e + 1) } def decimal(ds: List[Int]): Int = ds.foldRight((0,0))(h).head h :: Int -> (Int,Int) -> (Int,Int) h d (ds, e) = (f d ds, e + 1) where f :: Int -> Int -> Int f d ds = d * (10 ^ e) + ds decimal :: [Int] -> Int decimal ds = fst (foldr h (0,0) ds) def decimal(digits: List[Int]): Int = val e = digits.length-1 def f(d: Int, ds: Int): Int = d * Math.pow(10, e).toInt + ds digits match case Nil => 0 case d +: ds => f(d, decimal(ds)) decimal :: [Int] -> Int decimal [] = 0 decimal (d:ds) = f d (decimal ds) where e = length ds f :: Int -> Int -> Int f d ds = d * (10 ^ e) + ds The unnecessary complexity of the ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ functions on this slide is purely due to them being defined in terms of ๐’‡ . See next slide for simpler refactored versions in which ๐’‡ is inlined.
  4. def f(d: Int, acc: (Int,Int)): (Int,Int) = acc match case

    (ds, e) => (d * Math.pow(10, e).toInt + ds, e + 1) def decimal(ds: List[Int]): Int = ds.foldRight((0,0))(f).head f :: Int -> (Int,Int) -> (Int,Int) f d (ds, e) = (d * (10 ^ e) + ds, e + 1) decimal :: [Int] -> Int decimal ds = fst (foldr f (0,0) ds) def decimal(digits: List[Int]): Int = digits match case Nil => 0 case d +: ds => d * Math.pow(10, ds.length).toInt + decimal(ds) decimal :: [Int] -> Int decimal [] = 0 decimal (d:ds) = d*(10^(length ds))+(decimal ds) Same ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ functions as on the previous slide, but refactored as follows: 1. inlined ๐’‡ in all four functions 2. inlined e in the first two functions 3. renamed ๐’‰ to ๐’‡ in the last two functions
  5. Not every function on lists can be defined as an

    instance of ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ. ... Even for those that can, an alternative definition may be more efficient. To illustrate, suppose we want a function decimal that takes a list of digits and returns the corresponding decimal number; thus ๐‘‘๐‘’๐‘๐‘–๐‘š๐‘Ž๐‘™ [๐‘ฅ0 , ๐‘ฅ1 , โ€ฆ , ๐‘ฅn ] = โˆ‘!"# $ ๐‘ฅ๐‘˜ 10($&!) It is assumed that the most significant digit comes first in the list. One way to compute decimal efficiently is by a process of multiplying each digit by ten and adding in the following digit. For example ๐‘‘๐‘’๐‘๐‘–๐‘š๐‘Ž๐‘™ ๐‘ฅ0 , ๐‘ฅ1 , ๐‘ฅ2 = 10 ร— 10 ร— 10 ร— 0 + ๐‘ฅ0 + ๐‘ฅ1 + ๐‘ฅ2 This decomposition of a sum of powers is known as Hornerโ€™s rule. Suppose we define โŠ• by ๐‘› โŠ• ๐‘ฅ = 10 ร— ๐‘› + ๐‘ฅ. Then we can rephrase the above equation as ๐‘‘๐‘’๐‘๐‘–๐‘š๐‘Ž๐‘™ ๐‘ฅ0 , ๐‘ฅ1 , ๐‘ฅ2 = (0 โŠ• ๐‘ฅ0 ) โŠ• ๐‘ฅ1 โŠ• ๐‘ฅ2 This is almost like an instance of ๐‘“๐‘œ๐‘™๐‘‘๐‘Ÿ, except that the grouping is the other way round, and the starting value appears on the left, not on the right. In fact the computation is dual: instead of processing from right to left, the computation processes from left to right. This example motivates the introduction of a second fold operator called ๐‘“๐‘œ๐‘™๐‘‘๐‘™ (pronounced โ€˜fold leftโ€™). Informally: ๐‘“๐‘œ๐‘™๐‘‘๐‘™ โŠ• ๐‘’ ๐‘ฅ0 , ๐‘ฅ1 , โ€ฆ , ๐‘ฅ๐‘› โˆ’ 1 = โ€ฆ ((๐‘’ โŠ• ๐‘ฅ0 ) โŠ• ๐‘ฅ1 ) โ€ฆ โŠ• ๐‘ฅ๐‘› โˆ’ 1 The parentheses group from the left, which is the reason for the name. The full definition of ๐‘“๐‘œ๐‘™๐‘‘๐‘™ is ๐‘“๐‘œ๐‘™๐‘‘๐‘™ โˆท ๐›ฝ โ†’ ๐›ผ โ†’ ๐›ฝ โ†’ ๐›ฝ โ†’ ๐›ผ โ†’ ๐›ฝ ๐‘“๐‘œ๐‘™๐‘‘๐‘™ ๐‘“ ๐‘’ = ๐‘’ ๐‘“๐‘œ๐‘™๐‘‘๐‘™ ๐‘“ ๐‘’ ๐‘ฅ: ๐‘ฅ๐‘  = ๐‘“๐‘œ๐‘™๐‘‘๐‘™ ๐‘“ ๐‘“ ๐‘’ ๐‘ฅ ๐‘ฅ๐‘  Richard Bird The definition of ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ using a right fold is inefficient because it computes โˆ‘!"# $ ๐‘‘๐‘˜ โˆ— 10$&! by computing 10$&! for each ๐‘˜.
  6. If we look back at our initial recursive definition of

    ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’, we see that it splits its list parameter into a head and a tail. If we get ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ to split the list into init and last, we can make it more efficient by using Hornerโ€™s rule: We can then improve on that by going back to splitting the list into a head and a tail, and making ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ tail recursive: And finally, we can improve on that by defining ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ using a left fold: (โŠ•) :: Int -> Int -> Int n โŠ• d = 10 * n + d decimal :: [Int] -> Int decimal [] = 0 decimal (d:ds) = d*(10^(length ds)) + (decimal ds) def decimal(digits: List[Int]): Int = digits match case Nil => 0 case d +: ds => d * Math.pow(10, ds.length).toInt + decimal(ds) extension (n: Int) def โŠ•(d Int): Int = 10 * n + d decimal :: [Int] -> Int -> Int decimal [] acc = acc decimal (d:ds) acc = decimal ds (acc โŠ•d) def decimal(ds: List[Int], acc: Int=0): Int = digits match case Nil => acc case d +: ds => decimal(ds, acc โŠ• d) decimal :: [Int] -> Int decimal = foldl (โŠ•) 0 decimal :: [Int] -> Int decimal [] = 0 decimal ds = (decimal (init ds)) โŠ• (last ds) def decimal(digits: List[Int]): Int = digits match case Nil => 0 case ds :+ d => decimal(ds) โŠ• d def decimal(ds: List[Int]): Int = ds.foldLeft(0)(_โŠ•_)
  7. Recap In the case of the ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ function, defining it

    using a left fold is simple and mathematically more efficient whereas defining it using a right fold is more complex and mathematically less efficient def decimal(ds: List[Int]): Int = ds.foldRight((0,0))(f).head def f(d: Int, acc: (Int,Int)): (Int,Int) = acc match case (ds, e) => (d * Math.pow(10, e).toInt + ds, e + 1) decimal :: [Int] -> Int decimal ds = fst (foldr f (0,0) ds) f :: Int -> (Int,Int) -> (Int,Int) f d (ds, e) = (d * (10 ^ e) + ds, e + 1) decimal :: [Int] -> Int decimal = foldl (โŠ•) 0 (โŠ•) :: Int -> Int -> Int n โŠ• d = 10 * n + d def decimal(ds: List[Int]): Int = ds.foldLeft(0)(_โŠ•_) extension (n: Int) def โŠ•(d Int): Int = 10 * n + d ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ 1,2,3,4 = ๐‘‘0 โˆ— 103 + (๐‘‘1 โˆ— 102 + (๐‘‘2 โˆ— 101 + (๐‘‘3 โˆ— 100 + 0))) = 1 โˆ— 1000 + (2 โˆ— 100 + (3 โˆ— 10 + (4 โˆ— 1 + 0))) = 1234 ๐’…๐’†๐’„๐’Š๐’Ž๐’‚๐’ 1,2,3,4 = 10 โˆ— 10 โˆ— 10 โˆ— 10 โˆ— 0 + ๐‘‘0 + ๐‘‘1 + ๐‘‘2 + ๐‘‘3 = 10 โˆ— (10 โˆ— 10 โˆ— 10 โˆ— 0 + 1 + 2 + 3) + 4 = 1234