let replicate = https://ipfs.io/ipfs/QmQ8w5PLcsNz56dMvRtq54vbuPe9cNnCCUXAQp6xLc6Ccx/Prelude/List/replicate
in let exclaim = λ(t : Text) → t ++ "!"
in λ(x : Text) → replicate +3 Text (exclaim x)
... to this one: λ(x : Text) → [x ++ "!", x ++ "!", x ++ "!"]
... even though we haven't applied the function to any arguments yet. You can't perform this sort of simplification (in general) if the language is Turing-completeThis sort of automatic simplification comes in handy a lot when authoring configuration files. For example, if somebody objects to the import of the remote `replicate` function, you can just simplify the file and (voila!) all the imports are gone because they've all been inlined and reduced. Similarly, if somebody objects to excessive use of abstraction and functions you can similarly simplify them to remove all indirection
More generally, many automatic refactorings are not entirely safe but we do them anyway, assuming that the code will terminate (since intentional infinite loops are rare) and relying on tests and human reviewers to check for mistakes.
Depending on the program, substituting equals for equals might have to be rolled back for performance reasons; computing a mathematically equal value is no guarantee.
This is a pretty labor-intensive, mechanical guarantee. If I had to run config changes through code review (like SBT configs, for example), then that slows down velocity considerably. Having a language for your config offer these guarantees means that any committer can safely make edits, without having to go through process.
In the case of normalising a non-Turing-complete language, we (a) don't need any heuristics, beta-reduction is a complete and correct strategy and (b) things like the size of a function are useless at telling us whether we've reached a normal form. In fact, I would imagine that normal forms of real Dhall programs are generally much bigger than the programs themselves, since one of the main reasons to use a language like Dhall is to reduce repetition. Also, your heuristic is heavily dependent on the evaluation order: if we have a program like this:
(\x -> (\y -> x)) small-thing (duplicate 1000000 big-thing)
Then an evaluation strategy like call-by-name will never look at big-thing, since it evaluates the functions first and they discard it: (\x -> (\y -> x)) small-thing (duplicate 1000000 big-thing)
(\y -> small-thing) (duplicate 1000000 big-thing)
small-thing
small-value
On the other hand, an evaluation strategy like call-by-value will evaluate big-thing, resulting in some arbitrarily large value (which may cause your heuristic to halt); then it will create 1000000 duplicates of that value (again, causing a size-based heuristic to halt); then finally it will evaluate the functions and discard the big, duplicate expression: (\x -> (\y -> x)) small-thing (duplicate 1000000 big-thing)
(\x -> (\y -> x)) small-value (duplicate 1000000 big-thing)
(\x -> (\y -> x)) small-value (duplicate 1000000 big-value)
(\x -> (\y -> x)) small-value [big-value, big-value, ...]
(\y -> small-value) [big-value, big-value, ...]
small-valueBut practical programming is about writing programs that we actually want to run. The distinction between "takes far too long" and "hangs forever" is unimportant because real-world tasks have at least an informal deadline: how long the user is willing to wait. And most performance testing is done by actually running programs, not via static analysis alone, because we want to know how fast the code is on real-world hardware.
This particular language is something you'd use to generate large, repetitive configurations. It makes sense for that use case that you'd want to make sure all macros can be expanded. But you don't have to prove this statically, because you can actually run the program and look at what it generates. Doing config file generation using a Turing-complete language would also work fine; if you accidentally create an infinite loop (or just very slow code), you can hit control-C and fix the bug.
(\x -> x x) (\x -> x x)But all other things are not equal. There is a cost to giving up Turing completeness. The language must now ensure additional properties about the code, mostly related to termination and productivity. The two strategies for this are reducing functionality offered by the language until you can prove all programs are good and introducing tools into the language to write compiler-verified proofs about your code.
Dhall is an example of the former approach. It removes enough pieces that it can only express computations that terminate.
Language like Idris, Agda, and Coq are examples of the latter. They provide rich systems that allow you to express proofs of various properties as needed to avoid Turing completeness.
Both categories of language are currently difficult to use for general purposes software. Turing completeness is a sacrifice made to ease writing software at the cost of making certain bugs possible.
At the moment, the best times to use a non-Turing complete language are when you have a problem domain where you can be satisfied with a reduced power language, or when you want to go all the way in the opposite direction and write machine-checked proofs about your software.
As we study these things more, we find ways of easing the burden of using languages that aren't Turing complete. Maybe in the far future, systems will be good enough that most software can comfortably be written in languages that aren't Turing complete. It's a worthy aspiration.
It's not now, though.
A lot of security vulnerabilities in programs essentially boil down to your code being an ad-hoc parser for an (often implicit) language that's more powerful than you wanted. Turing-completeness is especially dangerous, and you want to avoid it if you don't actually need it.
This is a very insightful way of looking at things, and is a research area called langsec.
See:
- Papers and public talks of Meredith L. Patterson
- https://www.gwern.net/Turing-complete for some examples of unexpected Turing-completeness
Can I ask why Turing-completeness is "especially dangerous"?
Let's say we have a pure language for e.g. generating a JSON value (nested lists/maps of strings, floats, nulls and booleans). It has no primitives other than null, true/false, string, float, list, map and function literals, and corresponding "arithmetic" functions for each type.
What would the safety and security differences be between such a language being Turing-complete and not being Turing-complete?
Programs in these languages can't "do" anything, other than calculate. The only problem I can think of is making denial-of-service easier, by e.g. generating an infinitely large list and running out of memory:
(\x -> [ null ] ++ (x x)) (\x -> [ null ] ++ (x x))
This will generate `[ null, null, null, ... ]` until it runs out of memory. However, we could still blow the memory without Turing-completeness, e.g. by growing an exponentially large datastructure for a large number of steps (basically like a zip bomb): (\f xs -> case xs of
nil -> [ null ]
(cons y ys) -> (f f ys) ++ (f f ys))
(\f xs -> case xs of
nil -> [ null ]
(cons y ys) -> (f f ys) ++ (f f ys))
[ null, null, null, null, null, ...and so on 1000 times ]
This recursion is well-founded, and structurally-decreasing since we only call `f` with a strictly-decreasing value of `xs`, until we hit the base-case for `xs = nil` where we return `[ null ]`. In the non-base-case we recurse twice to generate two identical lists, then append them together. This results in a list with 2^1000 elements, easily consuming all available memory.Hence for security, we need to kill programs which go over some predefined time/memory bounds regardless of whether or not they're Turing-complete. Hence my question about whether or not Turing-completeness is "especially dangerous", in comparison to e.g. I/O primitives.
You can write expressions in Dhall that will take a long time to evaluate (see the Ackerman example elsewhere in this thread), but it's harder to do so by accident. Totality checking is worthwhile for the same reason that seatbelts are worthwhile: they don't protect against all accidents, but they do protect against a lot of them
Don't get me wrong, I'm all in favour of totality! My question was mostly about the "especially dangerous" wording. In my opinion, the major advantage of using Dhall for config instead of say, Scheme, is that it's pure/referentially-transparent/has-no-IO/etc., which everyone seems to be ignoring in favour of debating whether or not totality is desirable
(Of course, Dhall does have "I" (but not "O") in the form of imports, but these are "morally pure" as long as URL contents are stable, similar to Nix's imports being "morally pure" as long as builds are deterministic).
> The latter example you gave will still not type-check in Dhall
Incidentally, how does Dhall check for totality? I know it's based on the calculus of constructions; does it use syntactic guardedness checks like Coq?
Whilst I like totality, I'm still undecided about whether it's worth enforcing in general-purpose languages (e.g. Agda, rather than Dhall), since totality checking is still quite restrictive. The usability balance for static typing was crossed by Hindley-Milner, but I'm yet to see such an unobtrusive approach to totality checking (whenever my Coq or Agda projects become more than toys, they tend to get wrapped up in a monadic `Delay` to avoid fighting things like syntactic guardedness!)
For example, if you write:
let x = x in x
... then the second `x` will be flagged as an unbound variable because `let`-bound variables are not in scope for their own definitions. Similarly, there is no `let rec` in DhallThat implies that if you want a user-defined recursive type that was not a list then you have to explicitly Boehm-Berarducci-encode the recursive type and its inhabitants. For example, if you wanted to encode the following Haskell data type in your configuration:
data Tree = Node Integer | Branch Tree Tree
example :: Tree
example = Branch (Node 1) (Branch (Node 2) (Node 3))
... you would encode it like this: λ(Tree : Type)
→ λ(Branch : Tree → Tree → Tree)
→ λ(Node : Integer → Tree)
→ Branch (Node 1) (Branch (Node 2) (Node 3))
You can think of Boehm-Berarducci encoding as "anonymous recursion" when you write it this way because every inhabitant of such a "recursive" type includes the datatype definition in the value (if you view the lambda-bound type and constructors as the datatype definition)Or to put it another way, if you ignore Dhall's support for builtin types, builtin functions, and imports then Dhall is literally the exact same thing as System Fw . You're used to thinking of System Fw or the calculus of constructions as underlying core languages for a higher-level language semantically defined in terms of the core language like Coq, Agda or Haskell. However, in Dhall there is no underlying core language: you are assembling raw System Fw expressions. Since the most pedantic definition of System Fw requires explicit polymorphism and doesn't support recursion then Dhall behaves the same way, too.
I agree, though, that the lack of (unsafe) IO is an even bigger deal. Like you mentioned, if you use immutable URLS (such as IPFS) then they are basically pure. Even if you do use mutable URLs, you can always use the ability to normalize programs to purify them of imports (and you can also use Dhall's API to safely and statically verify that an expression is devoid of imports).
In practice, the fact that the program cannot hang is probably the biggest point. Every programmer has experienced bugs where the program runs into an infinite loop or deadlocks. Those are the nastier bugs. Removing Turing-completeness removes that whole class of bugs, similar to how Rust removes the whole class of data race bugs.
For a non-turing-complete language, it may be possible to test such properties.
It's crucial to understand that this distinction is not one of how practical it is to determine properties; once you open the door to Turing-completeness, all ability to reason about a program effectively flies out the window. You might be able to come up with rules-of-thumb that determine an interesting property of a program in a Turing-complete language some of the time, but there will always exist programs for which your rules-of-thumb are insufficient.
There were several attempts at using a programming language the tool was written in for storing its configuration. None worked well enough to catch up as a general trend, and not because the languages used were Turing-complete.
My opinion is that a programmer has seen for a short moment what sysadmins do and decided to make something to help in the task, without really understanding what's needed and what works, what doesn't work, and why.