Another option, of course, is to allow controlled impurity, such as unsafePerformIO
in Haskell.
Here's the approach we took in Mercury.
Mercury is a logic language, like Prolog, but with strong types, strong modes, and strong determinism. When it came to implementing a foreign function interface, we decided that this should have strong compile-time guarantees, too.
Mercury defines three levels of purity:
- A procedure is
pure
when the set of solutions (i.e. its "outputs") depends only on the input arguments. Note that this doesn't mean that it can't do I/O! Mercury has linear modes which allow a pure way to perform I/O, similar to linear types in Clean or monads in Haskell, and strong determinism ensures that you can't backtrack over I/O.
- A procedure is
semipure
if it is pure, but its semantics may be affected by impure predicates.
- A procedure is
impure
if it may perform I/O or modify hidden state without declaring it in its interface.
You can think of semipure
as meaning that it can "read" hidden state, but not "write" it.
No procedure, even an impure one, may change the declarative semantics of pure code. That is, it must honour its type/mode/determinism declaration.
Every place where an impure or semipure procedure is called must be annotated:
impure_predicate(X,Y,Z) :-
this_is_a_pure_call(X,Y),
impure this_is_not(Y,Z),
% etc etc
You can also make a promise to the compiler that this impure code is actually used in a pure way:
pure_predicate(X,Y,Z) :-
this_is_a_pure_call(X,Y),
% The inside of this goal is impure, but the outside is pure.
promise_pure (
impure this_is_not(Y,Z)
),
% etc etc
You can also promise purity at the procedure level via a pragma.
Promises of course can't be checked, but as Henry Baker rightly said, if you lie to your compiler, it will get its revenge.