Hacker News new | ask | show | jobs
by pantaloony 2148 days ago
I don’t get the cost claims. The time it takes to note which type I intend something to be is mostly either so low that I recover it via improved hints and such very quickly, or larger but only because I’m documenting something complex enough that I should have documented it anyway, whether or not I was using static types, because it’ll be hell for other people or future-me to figure out otherwise. It seems like a large time savings to me—throw in faster and more confident refactoring and stuff like that, and it’s not even close.

I just don’t get how people are working that it represents a time cost rather than a large time savings. I don’t mean that as a dig, I just mean I genuinely don’t know what that must look like. And I’ve written a lot more code in dynamic languages, and got my start there, so it’s not like I “grew up” writing Java or something like that.

4 comments

I agree with you. There is a related joke (?) that goes like this:

"I don't like to waste time writing tests, because I need that time to fix bugs on production that happened because I don't write tests".

The relation to static typing is that static types are a kind of test the computer automatically writes for you.

I think the general feeling is that there are some code patterns that are safe and easy to do with dynamic typing, but impossible with simple type systems or more complex with more advanced type system.

An example would be Common Lisp's `map` function [0] (it takes a number of sequences and a function that has as many parameters as there are sequences). It would be hard to come up with a type for this in Java, and it would be a pretty complicated type in Haskell.

Another example of many people's experience with static typing is the Go style of language, where you can't write any code that works for both a list of strings and a list of numbers. This is no longer common, but it used to be very common ~10-15 years ago and many may have not looked back.

[0] http://www.lispworks.com/documentation/HyperSpec/Body/f_map....

Haskell's not too bad once you understand ZipList

http://learnyouahaskell.com/functors-applicative-functors-an...

    max <$> ZipList [1,2,3,4,5,3] <*> ZipList [5,3,1,2]  
    > [5,3,3,4]
Yeah it's not at all complicated in Haskell. I'm not sure what GP is talking about.
I replied to the parent as well, but not only is the solution the parent showed significantly more complex than the CL version, I'm not even sure it actually does what I asked.

More explicitly, the expression there seems to rely on knowing the arity of the function and the number of lists at compile time. Basically, I was asking for a function cl_map such that:

    cl_map foo [xs:[ys:[zs:...]]] = foo <$> xs <*> ys <*> zs <*> ...
Edit: found a paper explaining that this is not possible in Haskell, and showing how the problem is solved in Typed Scheme: https://www2.ccs.neu.edu/racket/pubs/esop09-sthf.pdf
Sure it's possible in Haskell. I'm not sure where in that paper you got the impression it isn't. Of course one can't define variadic functions in Haskell, but that's a more fundamental difference from Clojure, not a "code pattern that [is] safe and easy to do with dynamic typing, but impossible with simple type systems or more complex with more advanced type system."

    > traverse_ print (sequenceA [ZipList [1,2], ZipList [3,4]])
    [1,3]
    [2,4]
As far as I can tell, your example calls a unary function on each element of a list of lists. It's solving the variadic part of map, but not the part where I can call an N-ary function with each element of N lists.

Basically, instead of your example I would like to do something like this:

    > cl_map (+) [ZipList [1,2,3], ZipList [4,5,6]]
    [5,7,9]

    > cl_map (+ 3) [ZipList [1,2,3]]
    [4,5,6]

    > cl_map max3 [ZipList [1,2], ZipList [3,4], ZipList [5,6]] where max3 x y z = max x (max y z)
    [5, 6]
Can this be done? What is the type of cl_map?

Note: If this doesn't work with ZipList, that's ok - the important part is being able to supply the function at runtime. Also, please don't assume that the function is associative or anything like that - it's an arbitrary function of N parameters.

I will start by saying it took me a while to even parse the expression you provided. Whoever thought that inventing new operators is a way to write readable code should really be kept far away from programming languages. The article you provided didn't even bother to give a name to <*> and <$> so I could at least read them out to myself.

Anyway, bitter syntax sugar aside, the way you wrote the function I proposed was... a completely different function with similar results, which does not have the type I was asking for, and you only had to introduce 2 or 3 helper functions and one helper type to do it. I wanted to work with functions and lists, but now I get to learn about applicatives and ZipLists as well... no extra complication required!

Edit to ask: could this method be applied if you didn't know the number of lists and the function at compile time? CL's map would be the equivalent of a function that produces the expression you have showed me, but it's not clear to me that you could write this function in Haskell.

Edit2: found a paper explaining that this is not possible in Haskell, and showing how the problem is solved in Typed Scheme: https://www2.ccs.neu.edu/racket/pubs/esop09-sthf.pdf

In my opinion, with few exceptions, the kind of programs advocates of dynamic typing want to write that static typing would have trouble dealing with, are artificial and not the common case. (Not "map" though, I need to review that case, but "map" is definitely a common and useful function!)

> Another example of many people's experience with static typing is the Go style of language

Remember that a lot of backlash against Go's type system comes from static typing advocates used to more expressive static type systems :) It'd be a shame if, after all we complained about Go's limitations, newcomers held Go as an example of why static typing is a roadblock...

> In my opinion, with few exceptions, the kind of programs advocates of dynamic typing want to write that static typing would have trouble dealing with, are artificial and not the common case. (Not "map" though, I need to review that case, but "map" is definitely a common and useful function!)

I mostly agree, don't get me wrong. And it's important to note that Common Lisp's `map` functions do more than what people traditionally associate with `map` - they basically do `map(foo, zip(zip(list1, list2), list3)...)`.

Still, this is a pretty useful property, and it is very natural and safe to use or implement, while being impossible to give a type to in most languages.

C++ can do it with the template system, as can Rust with macros (so, using dynamic typing at compile time).

Haskell can make it look pretty decent (if you can stand operator soup) by relying on auto-currying and inline operators and a few helper functions. I would also note that the Haskell creators also though that this functionality is useful, so they implemented some of the required boilerplate in the standard lib already.

In most languages, you can implement it with lambdas and zips (or reflection, of course).

So I think that this is a nice example of a function that is not invented out of thin air, is useful, is perfectly safe and static in principle, but nevertheless is impossible to write "directly" in most statically typed languages.

Just to show the full comparison, here is how using this would look in CL, Haskell and C#:

    CL 
        (map 'list #'max3 '(1 3 5) '(-1 4 0) '(6 1 8)) 
    Haskell
        max3 <$> ZipList [1 3 5] <*> ZipList [-1,4,0] <*> ZipList [6,1,8]
        OR
        (<*>) ((<*>) ((<$>) max3 (ZipList [1,2])) (ZipList [-1,4])) (ZipList [3,1])
    C#
        new int[]{1,3,5}.Zip<int,int,Func<int,int>>(new int[]{-1,4,0}, (a,b) => (c) => max3(a, b, c)).Zip(new int[]{6, 1, 8}, (foo,c) => foo(c))
Note only the CL version, out of all these languages, can work for a function known at runtime instead of compile-time. None of the static type systems in common use can specify the type of this function, as they can't abstract over function arity.

Here's a paper showing how this was handled in Typed Scheme: https://www2.ccs.neu.edu/racket/pubs/esop09-sthf.pdf

IMO, Go is never a good example in static vs dynamic type system discussions (I mean, for this case: parametric polymorphism has been around since the 70s...).

The language developers themselves have repeatedly stated that its type system being very limited is intentional.

See e.g. here: https://github.com/golang/go/issues/29649#issuecomment-45482...

TBH, sometimes I wonder why they bothered with static typing at all...

Sure, I know Go is a low blow to static typing. But in this particular regard, Java or C# don't fare much better either.

This is not a question of just supporting parametric polymorphism, but of abstracting over the number of arguments of a function, which is not supported in almost any type system I know of; and then of matching the number of arguments received with the type of function you specified initially.

As an example of this, I've been working through Crafting Interpreters off and on. Chapter 5 consists mostly of discussion of the visitor pattern (is this the same thing as double dispatch?). The author notices that the amount of code that must be written to implement the design is so large that it's best to write a program to generate all of that code. I followed along as best I could, and at the end I wrote the equivalent code in my preferred language, which I've included in this comment:

  self[expr.type](self, expr)
It's not that difficult to do in Scala, which is probably the language that comes most close to a mainstream language that has a typesystem powerful enough to express this.

There are better languages for expressing this more natural (such as Idris) but in the end, the fallacy seems to lie in your claim that this would be "safe and easy to do with dynamic typing". That's what you think until you find out that your solution works in 99% of the cases, except in some special cases, because the compiler didn't have your back.

Examples are the standard sort functions in Java and python, which were bugged for a very long time.

Btw, here is the executable code in Scala: https://scalafiddle.io/sf/UrDu12b/1

Posting it for reference in case Scalafiddle is down:

  import shapeless._, ops.function._
  
  def multiMap[InputTypes <: HList, MapF, HListF, MapResult] (inputs: List[InputTypes])(mapF: MapF)
  (implicit fn: FnToProduct.Aux[MapF, InputTypes => MapResult]) = inputs.map(fn(mapF))
  
  
  val testList2Elems = List(
    3 :: "hi" :: HNil,
    5 :: "yes" :: HNil
  )
  
  multiMap(testList2Elems){ (num: Int, str: String) =>
    s"$num times $str is ${List.fill(num)(str).mkString}"
  }.foreach(println)
  
  
  val testList3Elems = List(
    3 :: "hi" :: 3 :: HNil,
    5 :: "yes" :: 2 :: HNil,
    2 :: "easy" :: 1 :: HNil
  )
  
  multiMap(testList3Elems){ (num: Int, str: String, mult: Int) =>
    s"$num * $mult times $str is ${List.fill(num*mult)(str).mkString}"
  }.foreach(println)
  
  
  
  // As expected, the compiler has our back and the following does not compile
  
  val testListWrongElems = List(
    3 :: "hi" :: HNil,
    5 :: "yes" :: "ups?" :: HNil
  )
  
  /*
   * Whoops, does not compile, list shape not good for multiMap :) 
   *
  multiMap(testListWrongElems){ (num: Int, str: String) =>
    s"$num times $str is ${List.fill(num)(str).mkString}"
  }.foreach(println)
  */
  
  /*
   * Whoops, does not compile, 2-sequences vs 3 argument function :) 
   *
  multiMap(testList2Elems){ (num: Int, str: String, mult: Int) =>
    s"$num * $mult times $str is ${List.fill(num*mult)(str).mkString}"
  }.foreach(println)
  */
I just checked the documentation of lisps implementation and it is different from my code. If the input lists have a different size, the shortest list decides the result length and everything else is discarded. This is of course possible to implement in Scala too, but I think it is a very bad thing to do that which can lead to bugs quite easy. I prefer my solution in that case.
Not that complicated even in C++

template <typename... Lists, typename Func> auto map(Func && func, Lists &&... lists) -> std::vector<decltype(func(std::declval<typename std::decay<Lists>::type::value_type>()...))>;

> std::declval<typename std::decay<Lists>::type::value_type>()

Yes, nothing hard to understand or discover about that at all...

Replying to add: actually, not only is the type obscure, it also relies on knowing the lists at compile time, while the CL function can do this at runtime (note that there is no dynamic behavior, it's simply that C++'s type system can't abstract over function arity).
How many hundreds of LOC would you like to write to support serializing and deserializing JSON for an endpoint that has a schema with around 20 fields, some of which are nested? If you are using Spring and Jackson, you will get to write around 300 LOC across 8 files before you get your hands on a single deserialized object. In any sane language you would use a library that enforces an arbitrary JSON schema to get the same validation guarantees provided by Jackson while writing maybe 25 LOC across maybe 2 files (if we generously count the JSON schema as code for this language but not for Java).

Is this an unusual use case?

Why would you write any of this yourself?

This is the classic use case for code generation. (And IMO one of the few justified ones.)

It seems like the more common approach among people who use Java is to write the 300 LOC across the 8 files then use the library to generate JSON schema, rather than the other way around. I wrote it myself because I did not want to tell my team that they had been doing things wrong for years before trying their approach once.
I would like to be able to explain the cost part better. It may just be personal bias of course.

1. There's no guarantee the correct theoretical model of your program fits the type system of your programming language.

2. Sometimes there are multiple correct models for different purposes in the same program, similar to how sometimes you need multiple views onto the same database tables.

3. Sometimes you just need the ability to bodge things.

> 2. Sometimes there are multiple correct models for different purposes in the same program, similar to how sometimes you need multiple views onto the same database tables.

Just wanted to point out that even though you can have multiple views or your database tables, they all still adhere to the same type system.

I guess it's a problem that can be overcome with type inference then? (I don't have to declare types on queries, updates, or views, just on the base tables.)