Type as Constraint: Why we need more type?

For me, programming was about how to map my mind to the world; from this view, it probably shows why I tend to use statically typed language. A strong model could ensure more people won't misunderstand our purpose. This article was going to show some strategy to promise the thing works in our mind won't be broken by others accident.

Now consider a situation, we had a list of something and we just sort it and we want to do binary-search with it. A subtle problem was we were hard to ensure the list was sorted. In the language such as Python or C, we had to promise this by ourselves. So we insert assert(sorted(list)) into our program like this:

def binary_search(lst):
    assert sorted(lst)
    # ...

p.s. More assertion in Python, I'm not a Python master. I have to say.

And then we feel the program became slower. Because we were so smart, we distinguish debug and release environment, there were no assertions in release mode. Now everything runs good, right? Well, if you wrote some programs with verified data, that's ok. But what if the lst came from the user input? It probably would break, or let's be honest, it would break. So remove checking from there shouldn't happen. But many languages cannot ensure it, we have to take responsibility.

With wrapping, we can make it a little bit better. We can do this:

func Sort(lst []interface{}) SortedList {
    // sorting
    return SortedList{lst: lst}

type SortedList struct {
    lst []interface{}

func BinarySearch(lst SortedList) interface{} {
    // you know, just ignore how we get the element
    return element

func main() {
    lst := []interface{}{1}
    sortedLst := Sort(lst)
    _ = BinarySearch(sortedLst)

But this is a weak promise, anyone can just use SortedList{lst: lst} to break it, but better than no promise and already easier to find out in code review.

The problem was this is not just easy to break, but it also didn't promise enough information for us. What if we modify the list before we use BinarySearch? This promise required some human work to check it. Now we want a more improved version. A promise that cannot be violated and doesn't need human work to check the mechanism. As usual, I would use pseudo-code(to get more information, ref to my another article):

sort[T: comparable](list: List[T]): Output
  Output = List[T] // type alias
  Output <: sorted // now we know the result type was a subtype of `sorted`
  // sorting

binary_search[T: comparable](list: List[T] <: sorted): T {
  // do something and get the answer
  return element;

and then we can use them like:

sorted_list: List[int] = sort(list);
_: int = binary_search(sorted_list); // `_` explicitly ignore value

The most important thing is when we do operations that do not fit trait sorted the program work as usual but the compiler won't think the value was sorted anymore, e.g.

sorted_list: List[int] = sort(list);
sorted_list.push(3); // add element
_: int = binary_search(sorted_list);

Now it won't get compiled, because the type mismatching, we can do push on List[int], but after that sorted_list won't belong to sorted trait anymore. Do you probably think why not make another type? Because although we, of course, can do that, it would be bad modeling in another case, when we just sort a list and later keep doing something on it and don't care about it's sorted or not til next sort, create another type would let we must keep converting type manually, which violate our spirit! Now back, sorted trait modeled the situation very well, we only have to give tag, the compiler checks the rest. We still have checking, we have to ensure the part need to do binary_search get List[T] <: sorted, but this is better than adding assertion everywhere. I hope you have a nice day and thanks for the read.

Date: 2020-01-16 Thu 00:00
Author: Lîm Tsú-thuàn