4 ms·
F# has both a `head` and `tryHead` function to handle lists that may or may not be empty. In general, `tryFoo` is a good pattern for naming functions that might
by munchler 2y ago
F# has both a `head` and `tryHead` function to handle lists that may or may not be empty. In general, `tryFoo` is a good pattern for naming functions that might fail.
Having a separate NonEmptyList type might seem like a good idea in theory, but in my experience, it leads to code that is significantly more complicated.
- truculent 2y agoHow does it make it more complicated? In my view, you’re moving the potential for failure to a different place (the constructor), rather than changing some fundamental property or introducing new complexity. Is it handling the construction of these types you find complicated? And is it simply not worth the guarantees?
- greener_grass 2y agoThe intuitive definition of NonEmptyList is: type NonEmptyList<'t> = 't * 't list But this cannot be passed to any function that expects a List<'t>. This is odd though, since intuitively, all non-empty lists are lists. See Rich Hickey's "Maybe Not" talk OOP solution is to use inheritance. Typical ML solution is to use type-classes. F# sits in an awkward middle-ground where neither is a perfect fit. I believe that dependently-typed languages solve this more elegantly. There's also the syntactic inconvenience of wrapping at construction, where in theory the compiler could figure it out for you. For example: let xs = 1 :: 2 :: 3 :: [] Here xs is non-empty, but we must tell the compiler: let xs = 1, 2 :: 3 :: [] TypeScript does a better job here (although a great cost!) What you end up with is massive code duplication or lots of extra function calls: xs |> NonEmptyList.toList |> List.map (fun x -> x + 1) |> NonEmptyList.unsafeFromList (I say this all as someone who really likes F#)
- munchler 2y agoYes, this is exactly what I was getting at. One ends up needing an explicit `toList` helper function that converts a non-empty list into a plain list. Pattern matching on a non-empty list is also inelegant, because it is implemented as a tuple, which creates a leaky abstraction.
- greener_grass 2y agoF# actually does have a good solution for pattern matching here, which is Active Patterns.
- josevalim 2y agoWe (Elixir) would rather define lists on top of non empty lists: list(a) = empty_list() or non_empty_list(a) So you should pass non-empty lists everywhere a list is expected. But you can’t pass a list where a non-empty one is expected. But overall, you are right: our concern is exactly all of the extra function calls that may now suddenly become necessary (and the tension mentioned in the article). We will review our design decisions as we keep on rolling out the type system!
- yawboakye 2y agofor when one expects a list to be non-empty, i think there’s strong argument in favor of an enforcement from the type checker, given that the prove will very likely be necessary. if not in the application code then in the tests.
- greener_grass 2y agoDoes this break down if we want some other types? e.g. list(a) = empty_list() or singleton_list(a) or two_or_more_list(a)
- jerf 2y ago"OOP solution is to use inheritance. Typical ML solution is to use type-classes." Yes, type classes can "work" to help a NonEmptyList degenerate to a normal List of some sort, if the function accepting the list accepts the type class instead of a hard-coded List. Unfortunately, at least for this exact task, taking hard-coded types is pretty common. I've sometimes wondered about the utility of a language that provided all of its most atomic types solely as typeclasses within its standard library, so that calling for a "List a" or "[a]" automatically was turned into the relevant type class. Inheritance doesn't actually work here. I assume you mean inheriting a NonEmptyList from some sort of List, from the perspective of a user facing a language that has a standard List and they want to create a NonEmptyList that things taking List will accept. Unfortunately, that is a flagrant violation of the Liskov Substitution Principle and will create architecturally-fragile code. Compilers can't enforce the LSP (with anything short of the dependently typed code you mention), so you can bash out a subclass that will throw an exception if you try to take the last element out of a NonEmptyList or violate the rules some other way, and if you pass your new NonEmptyList to something that happens to not do anything broken, you may get away with it, but by the standards of OO theory you're definitely "getting away" with something, not solving the problem. I haven't studied this extensively beyond just thinking here for a moment, but I don't think you can LSP-legally go the other way either. A subclassed List can't revoke a parent's NonEmptyList property that the list is guaranteed to not be empty. Again, you can bash the methods into place to make it work, but as this is a very basic standard library sort of thing for a language it really needs to be right. Edit: Yes, it's certainly illegal. You can take a List inherited from the NonEmptyList, have it be empty, but you have to be able to pass it to something accepting a NonEmptyList, but it will then be empty. So you can't LSP-legally inherit either way. (This is one of the "deep reasons" why inheritance is actually not a terribly useful architectural tool. It technically breaks really, really easily... like, probably most non-trivial uses of inheritance in most code bases is actually wrong somehow, even if never happens to outright crash. We tend to just code past the problem and not think about it too hard.)
- greener_grass 2y ago> You can take a List inherited from the NonEmptyList Shouldn't it go the other way? All NonEmptyLists are Lists, but not all Lists are NonEmptyList. So NonEmptyList inherits from List
- aloisdg 2y agoAs a fellow F# dev currently learning Elixir, this kind of thing is a burden
- bmitc 2y agoJust to clarify for the crowd though. In F#, `List.head` throws an exception when it fails whereas `List.tryHead` returns an `option`, which returns `None` when it fails instead of an exception. A general confusion of mine in Elixir is generally how libraries and functions treat errors. There's the common idiot of returning either `{:ok, ____}` or `{:error, ____}`, but what can be inside the error tuple is not always clear. The other thing is that sometimes a function can both throw an exception and also return a success tuple. Such cases are confusing to handle, and there's a large gap between handling cases like that and the philosophy of "let it crash", which I think is preached a little looser than it should actually be practiced. I do like F#'s way of disambiguating the two situations. The only issue I have in F#, which actually exists in every language that I know of that has exceptions, is that there is no way to know, up front and clearly, what exceptions can be thrown by a given function. This is particularly frustrating in F#, which has fantastic pattern matching for exceptions. I wish there was exhaustive pattern matching in F# for exception handling, such that it would warn you that you have an unhandled exception in a try/with expression (https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/exception-handling/the-try-with-expression https://learn.microsoft.com/en-us/dotnet/fsharp/language-ref...) but of course would allow for wildcard patterns.
- IshKebab 2y agoJava has checked exceptions and they are used, e.g. in Android. But it seems to be the exception (heh) rather than the rule. IMO the problem is proper exception handling with checked exceptions and wrapping each function (or at least small blocks) in try catch is just so insanely verbose that even though it is possible to get error handling as good as something like Rust, nobody actually does it in practice.
- sodapopcan 2y ago> There's the common idiot of returning either `{:ok, ____}` or `{:error, ____}`, but what can be inside the error tuple is not always clear. That's the result of Elixir being dynamic and not having built-in monads. You always have to check the docs/code. > The other thing is that sometimes a function can both throw an exception and also return a success tuple. I would say this is just poor design (though maybe someone could point me to an exemplary use of this?). As I see it (which is from how I've read it described and how most good lib do it): - If function can error and the caller can do something about it, return :ok/:error. - If the caller can't do anything about the error, raise. - If the function can't fail, return a raw value.