pure_fun
A Mix task that tells you which functions have side effects.
$ mix pure_fun --all
Payments.Core
fee/2 pure
settle/2 pure if the fun given as argument 2 is pure
charge/2 impure: performs I/O (IO.puts/1) via Payments.Core.log/1
1 pure, 1 conditional, 1 impure, 0 unknown (3 functions)
It reads the abstract code out of compiled .beam files, so it works on
both Elixir and Erlang, sees your code after macro expansion, and needs
no annotations to say something useful. Calls into your dependencies are
followed too, so an effect three libraries deep still comes back with
the function that has it.
Install
def deps do
[{:pure_fun, "~> 0.1", runtime: false}]
end
Do not add only: [:dev, :test]. An annotated module calls use PureFun,
so every environment that compiles it, :prod included, needs the
library. runtime: false keeps it out of the release.
Use
mix pure_fun # every module in the project
mix pure_fun Payments.Core # one module
mix pure_fun Payments.Core.fee/2 # one function
mix pure_fun --check # fail the build if a @pure_fun function is not pure
| Option | Effect |
|---|---|
--check |
Exit non-zero when an annotation is not kept. The CI mode. |
--all |
List pure functions too. |
--no-deps |
Do not follow calls into dependencies. Every call into a library is then unknown. |
--unknown |
List functions whose purity could not be determined. |
--private |
Include private functions. |
Verdicts
pure — computes a return value and nothing else. Raising is still
pure: an exception is a result, not an effect.
conditional — pure as long as the funs passed at the given
argument positions are pure. def each(list, fun) is not impure, it is
someone else's purity. Call sites are resolved individually, so
Enum.map(list, &String.upcase/1) is pure while
Enum.map(list, &IO.puts/1) is not.
impure — reaches an effect, reported with the function that has it
and the callee it came in through:
charge/2 impure: performs I/O (IO.puts/1) via Payments.Core.log/1
unknown — the trail was lost: an apply/3 with a computed module,
a fun the analyser could not resolve, or a module it has no knowledge of
and could not read.
That fourth verdict is the point. Folding "cannot tell" into either "pure" or "impure" would make every other answer untrustworthy.
Annotating
defmodule Payments.Core do
use PureFun
@pure_fun true
def fee(amount, rate), do: round(amount * rate)
end
mix pure_fun --check now fails if fee/2 ever grows an effect — a
functional core that stays a functional core.
A function that owns up to one kind of effect says which:
@pure_fun except: [:time]
def quote(amount), do: {DateTime.utc_now(), fee(amount, 0.03)}
The waiver belongs to quote/1 and to nothing else. A caller annotated
plain @pure_fun still fails on the clock its callee reads, which is what
stops a waiver from laundering effects through the rest of the call
graph. Telling the analyser about code it cannot see is a different job,
and :known below is where that lives.
A whole module can make the claim at once, which is the useful form for a functional core: a function added to it tomorrow is covered the day it lands.
defmodule Payments.Core do
use PureFun
@pure_fun_module except: [:time]
def fee(amount, rate), do: round(amount * rate)
end
A module-wide claim covers every public function and no private one. A
function inside it may narrow what its module waives, never widen it. To
exempt one function entirely, use Credo's own
# credo:disable-for-next-line PureFun.Check.Purity.
The classes an annotation may name are the ones the analyser reports:
:io, :file, :network, :system, :time, :random, :process,
:process_dictionary, :message, :message_receive, :ets,
:persistent_term, :mutable_state, :code_loading, :port,
:tracing, and the three that mean the trail was lost rather than an
effect found: :dynamic_call, :higher_order and :unknown. A
misspelt one is an error rather than a waiver of nothing, and fails the
compile.
Two verdicts keep a claim rather than breaking it. A higher-order
function is pure in itself — whoever hands it &IO.puts/1 fails on
their own annotation — so conditional passes. unknown does not: an
annotation nobody can check is the case this tool exists to report, and
waiving it takes saying except: [:unknown].
In Erlang:
-pure_fun_annotated([{fee, 2}, {quote, 1, [time]}]).
-pure_fun_module([{except, [time]}]).
As a Credo check
If the project already runs Credo, the same answers arrive as ordinary Credo issues, on the line the annotation sits on:
# .credo.exs
%{
configs: [
%{
name: "default",
checks: %{extra: [{PureFun.Check.Purity, []}]}
}
]
}
┃ [W] ↗ charge/2 is annotated @pure_fun but is impure: performs I/O
┃ (IO.puts/1) via Payments.Core.log/1
┃ lib/payments/core.ex:24:7 #(Payments.Core.charge)
Credo has to be a dependency of your project for the check to exist:
this library declares it as optional, so without it the check is not
compiled at all and pure_fun still brings nothing with it.
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
| Param | Effect |
|---|---|
known |
The same %{mfa => answer} map as mix.exs, merged over it. |
follow_deps |
Follow calls into dependencies. On by default; without it, a call into a library is unknown and an annotation that reaches one cannot be kept. |
The check reads annotations from the source and answers from the compiled code, which has three consequences worth knowing:
- Compile first. The check never compiles anything itself. A module
missing from the build, or older than the file it was compiled from,
is reported as unchecked rather than quietly passed. Run
mix compilebeforemix credoin CI. - One analysis per run. Purity is a property of the call graph, not of a file, so the project is analysed once and every annotation in the run reads its answer from that. Only code that an annotated module can reach is analysed. A module in the call graph of forty annotated functions is read, scanned and settled exactly once. A project with no annotations at all pays nothing: the check looks for them first and stops there.
- A
defwritten by a macro has no annotation in the source to find.mix pure_fun --checkreads the compiled attribute instead, and stays the way to cover those, along with Erlang modules.
A waiver that has outlived the effect it was written for is reported too, at low priority and with no exit status of its own: an annotation that is merely out of date says something untrue about the code, but it cannot make the check miss anything, so it does not fail a build.
Teaching it about a library
Unknown functions are reported, never guessed. When you know better than
the analyser, say so in mix.exs:
def project do
[
pure_fun: [
known: %{
{MyLib.Cache, :get, 1} => {:impure, :ets},
{MyLib.Money, :add, 2} => :pure,
{MyLib.Fold, :run, 2} => {:hof, [2]}
}
]
]
end
Dispatch
A call that picks its target at runtime is only as pure as the implementations it can reach, so all of them have to be pure for the dispatch to be pure. A protocol is just a behaviour whose implementations live in their own modules, so both go through one rule: a call to a module that declares the callback joins over the implementations.
defimpl String.Chars, for: Loud do
def to_string(loud) do
IO.puts("converting") # one impure implementation is enough
loud.name
end
end
"hello #{term}" # impure: it can reach that implementation
When the call site says which implementation it will reach, only that one counts:
for x <- xs, into: %{} # pure — only Collectable.Map can be reached
for x <- xs, into: stream # impure if a reachable Collectable writes
The same holds without a module name at all, because the function name
is often enough — calendar.date_to_string(y, m, d) names no module,
but date_to_string/3 is a Calendar callback, so the implementations
are known.
Implementations are found without being asked for: analysing a module pulls in the protocols it dispatches to along with their implementations.
How it works
- Scan. Every function body is walked for the things that can have
an effect: calls, fun references,
!,receive. Each call argument keeps a shape — a resolvable fun, a parameter of the enclosing function, a literal of a known type, or opaque. - Resolve. Each call becomes a dependency on another analysed function, a set of dependencies on the implementations it dispatches to, a known effect, or an unknown. Applying a parameter makes the function higher-order at that position rather than impure.
- Fixpoint. Effects propagate backwards along the call graph until nothing changes. Recursion needs no special case: the least fixpoint starts at "no effects" and only grows.
Leaves of the call graph are BIFs and NIFs whose abstract code shows
nothing — :ets.insert/2 compiles to erlang:nif_error(undef), and
believing that would report it as pure. PureFun.Knowledge is the
hand-maintained table that stops the analysis from bottoming out in a
lie, and it always wins over what the code appears to do.
What it cannot see
- Dynamic dispatch.
apply(module, fun, args)on computed values isunknown, as is a fun that was stored in a data structure and applied later. A dispatch is only resolved when the callback name is known. - Only the implementations it can see. A dispatch joins over the implementations in the analysis. One that is never compiled into the project — loaded at runtime, or in an application that was not analysed — cannot be accounted for.
Kernel.inspect/1is trusted. Inspecting is treated as pure rather than dispatched throughInspect, which would make almost every debug helper impure.- Creating a fun counts as calling it.
fn -> IO.puts("hi") endmakes the enclosing function impure even if the fun is never applied. Deliberately conservative. - The knowledge base is hand-maintained. It covers OTP and Elixir's
standard library; anything else is
unknownuntil you teach it. - Determinism is not proven. Non-termination, allocation, atom table growth and scheduling are not effects here.
Development
mix test
mix test --cover
mix test --include host_project # builds a project that depends on pure_fun
mix pure_fun --all # it analyses itself
Running it on itself reports every function of the analysis core as pure, and every function that touches beam files or Mix as impure.