Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: List extra basic #8

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 196 additions & 1 deletion src/DictList.elm
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ module DictList
-- Conversion
, toDict
, fromDict
-- list-extra
, last
, inits
, (!!)
, uncons
, maximumBy
, minimumBy
, andMap
, andThen
, takeWhile
, dropWhile
, unique
, uniqueBy
, allDifferent
, allDifferentBy
)

{-| Have you ever wanted a `Dict`, but you need to maintain an arbitrary
Expand Down Expand Up @@ -115,12 +130,19 @@ between an association list and a `DictList` via `toList` and `fromList`.
# JSON

@docs decodeObject, decodeArray, decodeWithKeys, decodeKeysAndValues

# ListExtra

@docs last, inits, (!!), uncons, maximumBy, minimumBy, andMap, andThen, takeWhile, dropWhile, unique, uniqueBy, allDifferent, allDifferentBy

-}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately, I think it probably makes sense to try to classify these under topical headings, thus splitting them up throughout the documentation -- it's nice to keep track of their origin in List.Extra in the source, but I think it's better in the documentation if they are divided topically.


import Dict exposing (Dict)

import Dict exposing (Dict, keys)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better not to expose keys, since we have our own DictList.keys as well. So, we can refer to Dict.keys explicitly.

import DictList.Compat exposing (customDecoder, decodeAndThen, first, maybeAndThen, second)
import Json.Decode exposing (Decoder, keyValuePairs, value, decodeValue)
import List.Extra
import Set
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be unused so far?



{-| A `Dict` that maintains an arbitrary ordering of keys (rather than sorting
Expand Down Expand Up @@ -535,6 +557,11 @@ getAt index (DictList dict list) =
|> Maybe.map (\value -> ( key, value ))
)

{-| Alias for getAt, but with the parameters flipped.
-}
(!!) : DictList comparable value -> Int -> Maybe ( comparable, value)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this needs a run of elm-format ...

(!!) =
flip getAt

{-| Insert a key-value pair into a `DictList`, replacing an existing value if
the keys collide. The first parameter represents an existing key, while the
Expand Down Expand Up @@ -938,6 +965,174 @@ fromDict dict =



-- LIST EXTRA

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to keep things organized nicely, we could move getAt and (!!) and any other things actually inspired by List.Extra down here -- it will help a bit when changes are made to List.Extra and we want to track them. (So, I'd suggest organizing the source to match where we got things from, whereas re-arranging the documentation topically).

I actually had considered putting the List.Extra stuff in a separate module, but I can see now that that won't work, because we'll want to make use of the DictList constructors which we don't expose (so aren't accessible from another module).


{-| Extract the last element of a list.
last (fromList [(1, 1), (2, 2), (3, 3)]) == Just (3, 3)
last (fromList []) == Nothing
-}
last : DictList comparable v -> Maybe ( comparable, v )
last xs =
toList xs
|> List.Extra.last

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will work, but doing a complete toList on the DictList will be somewhat inefficient -- we'll look up every key in the Dict, when we only really need to look up the last one. So, I'd suggest taking the opportunity to work on the internals of DictList directly -- something roughly like:

last (DictList dict list) =
    List.Extra.last list
        |> Maybe.map (\key -> DictList key (unsafeGet key dict))

That is, we can get the last key from the internal list directly, and then use unsafeGet to look up just that value (and it's not actually unsafe in this context, as long as our list and dict have remained consistent, which they will have unless we have bugs).


{-| Return all initial segments of a list, from shortest to longest, empty list first, the list itself last.

inits (fromList [(1, 1),(2,),(3, 3)]) == [fromList [], fromList [(1, 1)], fromList [(1, 1), (2, 2)], fromList [(1, 1), (2, 2), (3, 3)]]
-}
inits : DictList comparable v -> List (DictList comparable v)
-- @FIXME
inits list =
toList list
|> List.Extra.inits
|> List.map fromList

{-| Returns a list of repeated applications of `f`.

If `f` returns `Nothing` the iteration will stop. If it returns `Just y` then `y` will be added to the list and the iteration will continue with `f y`.
nextYear : Int -> Maybe Int
nextYear year =
if year >= 2030 then
Nothing
else
Just (year + 1)
-- Will evaluate to [2010, 2011, ..., 2030]
iterate nextYear 2010
-}
iterate : ((comparable, v) -> Maybe (comparable, v)) -> (comparable, v) -> DictList comparable v
iterate f x =
List.Extra.iterate f x
|> fromList

{-| Decompose a list into its head and tail. If the list is empty, return `Nothing`. Otherwise, return `Just (x, xs)`, where `x` is head and `xs` is tail.

uncons (fromList [(1, 1),(2, 2),(3, 3)] == Just ((1, 1), fromList [(2, 2), (3, 3)])
uncons empty = Nothing
-}
uncons : DictList comparable v -> Maybe ( (comparable, v), DictList comparable v)
uncons xs =
toList xs
|> List.Extra.uncons
|> Maybe.map (\(a, la) -> (a, fromList la))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one can also be optimized by working directly on the DictList internals -- that is, think about what you'd have to do to the dict and list separately in DictList dict list, like the example I gave for last.


{-| Find the first maximum element in a list using a comparable transformation
-}
maximumBy : (comparable2 -> a -> comparable1) -> DictList comparable2 a -> Maybe (comparable2, a)
maximumBy f ls =
toList ls
|> List.Extra.maximumBy (uncurry f)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one looks fine to me -- I don't think there's an optimization possible using the internals, since we'll have to get all the values one way or another. I suppose we might be able to avoid constructing the intermediate list by using a foldl (and then using something roughly like the implementation of List.Extra.maximumBy).


{-| Find the first minimum element in a list using a comparable transformation
-}
minimumBy : (comparable2 -> a -> comparable1) -> DictList comparable2 a -> Maybe (comparable2, a)
minimumBy f ls =
toList ls
|> List.Extra.minimumBy (uncurry f)

{-| Take elements in order as long as the predicate evaluates to `True`
-}
takeWhile : ((comparable, a) -> Bool) -> DictList comparable a -> DictList comparable a
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the signature of the callback function, I've mostly been preferring (comparable -> a -> Bool) ... that is, for the callbacks, supplying the key and value as separate parameters ... I think it makes for more natural callback writing, but there would be arguments either way.

takeWhile predicate xs =
toList xs
|> List.Extra.takeWhile predicate
|> fromList

{-| Drop elements in order as long as the predicate evaluates to `True`
-}
dropWhile : ((comparable, a) -> Bool) -> DictList comparable a -> DictList comparable a
dropWhile predicate list =
toList list
|> List.Extra.dropWhile predicate
|> fromList

{-| Remove duplicate values, keeping the first instance of each element which appears more than once.

unique [0,1,1,0,1] == [0,1]
-}
unique : DictList comparable1 comparable2 -> DictList comparable1 comparable2
unique list =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one doesn't really make sense in this form, because the keys will necessarily be unique -- so this will always be an identity function as implemented here -- it will never remove anything.

I suppose we could change it so that it only considers the values -- the puzzle then would be which key should be chosen where two keys have equal values ...

toList list
|> List.Extra.unique
|> fromList

{-| Drop duplicates where what is considered to be a duplicate is the result of first applying the supplied function to the elements of the list.
-}
uniqueBy : (comparable1 -> a -> comparable2) -> DictList comparable1 a -> DictList comparable1 a
uniqueBy f list =
toList list
|> List.Extra.uniqueBy (uncurry f)
|> fromList
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one, on the other hand, could remove some values, so it could do something. I wonder which value the implementation retains (since the "duplicate" values are not, themselves, necessarily equal)? That would be worth documenting (I suppose List.Extra might not document it).


{-| Indicate if list has duplicate values.

allDifferent [0,1,1,0,1] == False
-}
allDifferent : DictList comparable1 comparable2 -> Bool
allDifferent list =
-- @TODO Should this method just use the values, or also the keys
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very good question!

In some cases, it makes sense to have two versions of a function -- for instance, I ended up with both getAt and getKeyAt. So, you might want to have an allDifferent which considers both keys and values, and then also an allDifferentValues that considers only values, or something like that.

values list
|> List.Extra.allDifferent

{-| Indicate if list has duplicate values when supplied function are applyed on each values.
-}
allDifferentBy : (comparable1 -> a -> comparable2) -> DictList comparable1 a -> Bool
allDifferentBy f list =
toList list
|> List.Extra.allDifferentBy (uncurry f)

{-| Map functions taking multiple arguments over multiple lists. Each list should be of the same length.

((\a b c -> a + b * c)
|> flip map [1,2,3]
|> andMap [4,5,6]
|> andMap [2,1,1]
) == [9,7,9]
-}
andMap : DictList comparable a -> DictList comparable (a -> b) -> DictList comparable b
andMap l fl =
let
keyList = keys l
lList = values l
flList = values fl
in
List.Extra.andMap lList flList
|> List.Extra.zip keyList
|> fromList

{-| Equivalent to `concatMap`. For example, suppose you want to have a cartesian product of [1,2] and [3,4]:

[1,2] |> andThen (\x -> [3,4]
|> andThen (\y -> [(x,y)]))

will give back the list:

[(1,3),(1,4),(2,3),(2,4)]

Now suppose we want to have a cartesian product between the first list and the second list and its doubles:

[1,2] |> andThen (\x -> [3,4]
|> andThen (\y -> [y,y*2]
|> andThen (\z -> [(x,z)])))

will give back the list:

[(1,3),(1,6),(1,4),(1,8),(2,3),(2,6),(2,4),(2,8)]

Advanced functional programmers will recognize this as the implementation of bind operator (>>=) for lists from the `Monad` typeclass.
-}
andThen : (comparable -> a -> DictList comparable b) -> DictList comparable a -> DictList comparable b
andThen =
concatMap

concatMap : (comparable -> a -> DictList comparable b) -> DictList comparable a -> DictList comparable b
concatMap f xs = empty
-- map f xs
-- |> toList
-- |> values
-- |> concat

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes, it isn't obvious to me either what andMap and andThen ought to do in the context of DictList -- that would take some thinking.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is such an interesting question! I've been thinking about that and talking with a bunch of people.

One sensefull type would look like the following: (DictList comparable (a -> b)) -> (DictList comparable a) -> (DictList comparable b). For the concrete implementation you would probably match the keys and throw away all (a->b)'s / a's when there are no matching keys. This requires for example no instance of pure (the other function which makes sense in the context). To implement pure you would need some mempty for the keys.

Note: Dict.Map doesn't implement that in haskell
Note2: https://hackage.haskell.org/package/total-map-0.0.6/docs/Data-TotalMap.html does implement the above idea

For andThen the signature could look like the following:

andThen : (comparable -> a -> DictList comparable b) -> DictList comparable a -> DictList comparable b

What could this function do:

  • This function maps a key/value pair to a new DictList, so it kinda generates a new dict list.
  • The result could then have the specific key removed and inserted all keys/values of the result in b. This smashing together could be some kind of merge operation.

In this world you could reimplement:

  • remove k dictList = andThen (\k2 v -> if k == k2 then empty else fromList [(k2, v)]) dictList
  • update k v dictList = andThen (\k2 v2 -> fromList [(k2, if k == k2 then v else v2)]) dictList

Does it even remotely makes sense?

TL;DR

  • Don't expect to have some form of product/cartesian behaviour for andMap
  • I doubt there is too much value in return / pure implementations

-----------
-- Internal
-----------
Expand Down
82 changes: 82 additions & 0 deletions tests/src/ListExtraTests.elm
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
module ListExtraTests exposing (tests)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I would suggest here is looking at https://github.com/elm-community/list-extra/blob/6.0.0/tests/Tests.elm and:

  • copy the relevant tests
  • make whatever the smallest changes are to fit the function signatures here

That is, instead of working from ListTests.elm as a base, work from the actual tests for List.Extra -- the idea is to prove that we can pass the List.Extra tests (modified as little as needed to fit our context).

Then, in a separate test file, you could test anything else you want to test -- i.e. things that List.Extra doesn't test, or behaviours that are unique to DictList.

{-| This is an adaptation of the `List` tests in elm-lang/core, in order
to test whether we are a well-behaved list.
-}

import Test exposing (..)
import Expect exposing (Expectation)
import Maybe exposing (Maybe(Nothing, Just))
import DictList exposing (..)
import List.Extra


tests : Test
tests =
describe "List Tests"
[ testListOfN 0
, testListOfN 1
, testListOfN 2
, testListOfN 5000
]


toDictList : List comparable -> DictList comparable comparable
toDictList =
List.map (\a -> ( a, a )) >> DictList.fromList


testListOfN : Int -> Test
testListOfN n =
let
xs =
List.range 1 n |> toDictList

xsOpp =
List.range -n -1 |> toDictList

xsNeg =
foldl cons empty xsOpp

-- assume foldl and (::) work
zs =
List.range 0 n
|> List.map (\a -> ( a, a ))
|> fromList

sumSeq k =
k * (k + 1) // 2

xsSum =
sumSeq n

mid =
n // 2
in
describe (toString n ++ " elements")
[ test "last" <|
\() ->
if n == 0 then
Expect.equal (Nothing) (last xs)
else
Expect.equal (Just ( n, n )) (last xs)
, test "inits" <|
\() ->
if n == 0 then
Expect.equal [empty] (inits empty)
else
Expect.equal [empty, fromList [(1,1)], fromList [(1,1), (2,2)]] (inits (fromList [(1,1), (2,2)]))
, test "(!!)" <|
\() ->
if n == 0 then
Expect.equal Nothing ((!!) empty 0)
else
Expect.equal (Just (n, n)) ((!!) xs (n-1))
, test "uncons" <|
\() ->
if n == 0 then
Expect.equal Nothing (uncons empty)
else
-- @TODO Generalize
Expect.equal (Just ((1, 1), fromList [(2, 2), (3, 3)])) (uncons (fromList [(1, 1), (2, 2), (3, 3)]))
]
10 changes: 5 additions & 5 deletions tests/src/ListTests.elm
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ tests =

toDictList : List comparable -> DictList comparable comparable
toDictList =
List.map (\a -> (a, a)) >> DictList.fromList
List.map (\a -> ( a, a )) >> DictList.fromList


testListOfN : Int -> Test
Expand All @@ -40,7 +40,7 @@ testListOfN n =
-- assume foldl and (::) work
zs =
List.range 0 n
|> List.map (\a -> (a, a))
|> List.map (\a -> ( a, a ))
|> DictList.fromList

sumSeq k =
Expand Down Expand Up @@ -77,7 +77,7 @@ testListOfN n =
if n == 0 then
Expect.equal (Nothing) (head xs)
else
Expect.equal (Just (1, 1)) (head xs)
Expect.equal (Just ( 1, 1 )) (head xs)
, describe "List.filter"
[ test "none" <| \() -> Expect.equal (empty) (DictList.filter (\_ x -> x > n) xs)
, test "one" <| \() -> Expect.equal [ n ] (values <| DictList.filter (\_ z -> z == n) zs)
Expand All @@ -95,8 +95,8 @@ testListOfN n =
, test "all" <| \() -> Expect.equal (empty) (drop n xs)
, test "all+" <| \() -> Expect.equal (empty) (drop (n + 1) xs)
]
-- append works differently in `DictList` because it overwrites things with the same keys
, test "append" <| \() -> Expect.equal (xsSum {- * 2-}) (append xs xs |> foldl (always (+)) 0)
-- append works differently in `DictList` because it overwrites things with the same keys
, test "append" <| \() -> Expect.equal (xsSum {- * 2 -}) (append xs xs |> foldl (always (+)) 0)
, test "cons" <| \() -> Expect.equal (values <| append (toDictList [ -1 ]) xs) (values <| cons -1 -1 xs)
, test "List.concat" <| \() -> Expect.equal (append xs (append zs xs)) (DictList.concat [ xs, zs, xs ])
, describe "partition"
Expand Down
2 changes: 2 additions & 0 deletions tests/src/Main.elm
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import DictTests
import DictListTests
import Json.Encode exposing (Value)
import ListTests
import ListExtraTests
import Test exposing (..)
import Test.Runner.Node exposing (run)

Expand All @@ -21,4 +22,5 @@ all =
[ DictListTests.tests
, DictTests.tests
, ListTests.tests
, ListExtraTests.tests
]