Share
Embed
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.
Philip Schwarz PRO
April 21, 2024
Transcript
8 slides
-
CHEAT-SHEET Folding #4 โถ / \ ๐๐ โถ / \
๐๐ โถ / \ ๐๐ โถ / \ ๐๐ ๐ / \ ๐๐ ๐ / \ ๐๐ ๐ / \ ๐๐ ๐ / \ ๐๐ ๐ @philip_schwarz slides by https://fpilluminated.com/ -
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 -
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. -
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 -
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 ๐. -
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)(_โ_) -
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