In this section we instantiateLNLHaskwith linear mutable arrays. In his paper “Linear types can change the world!”, Wadler (1990) argues that mutable data structures like arrays can be given a pure functional interface if they are only accessed linearly. To understand why, consider a non-linear program with purely functional arrays that writes two values to index 0 of the array one after another, and then looks up the value of index 0.
let arr1 = write 0 arr "hello" in
let arr2 = write 0 arr "world" in arr1[0]
If write were to update the array in place, the program would return "world" instead of "hello". Linear types force us to serialize the operations on arrays so that reasonable equational laws still hold, even when performing destructive updates.
Here we expand Wadler’s example to describe slices of an array. Consider an operation
slice arr ithat partitions an arrayarraround the indexi. As long as the operations on each slice are restricted to their domains, the implementation ofslice can alias the same array. Furthermore, as long as we keep track of when two slices alias the same array, we can merge slices back together with zero cost.
To implement linear arrays inLNLHask, we first add a new type for arrays of non-linear values.
data ArraySig ty = MkArray Type Type
type Array token α = MkLType (MkArray token α)
The first argument ktoArray k α is a token that keeps track of the array being aliased— different arrays will be initialized with different tokens. The second argumentα is the type of values stored in the array.
class HasMELL exp⇒HasArray exp where alloc ∶∶ CAddCtx x (Array k α) γ γ'
⇒Int→α→ (Var exp (Array k α)→ exp γ' σ)→ exp γ σ dealloc ∶∶ exp γ (Array k α)→ exp γ LUnit
size ∶∶ exp γ (Array token a)→ exp γ (Array token a ⊗ Lower Int) read ∶∶ Int→ exp γ (Array k α)→ exp γ (Array k α⊗ Lower α) write ∶∶ Int→ exp γ (Array k α)→α→ exp γ (Array k α)
slice ∶∶ Int→ exp γ (Array k α)→ exp γ (Array k α⊗ Array k α) combine ∶∶ CMerge γ1 γ2 γ
⇒exp γ1 (Array k α)→ exp γ2 (Array k α) → exp γ (Array k α) Figure 4.5: Interface to linear arrays.
The interface to linear arrays is given in Figure 4.5. The interface can allocate new arrays and drop the pointers to existing ones. The size operation returns the length of a particular slice of an array; the read and write operations will fail at runtime if their arguments are not in the domain of their slice. In other words, we should think of a slice of an array as a standalone piece of data, indexed starting from zero.
The operation slice takes an index and an array, and outputs two aliases to the same array with domains partitioned around the index. Dually,combine takes two aliases to the same array and combines their bounds.
4.5.1 Implementation
We instantiate the HasArray signature by extending the shallow embedding. A value of type Array k α consists of a primitive IO array as well as a list of indices corresponding to the current slice of an array. Because these indices tend to be grouped into ranges, they are represented as a set of intervals. We write Range for the type (Int,Int) of inclusive ranges of integers, and the type [Range]for ordered, non-overlapping lists of ranges.
newtype instance LVal Shallow (Array k α) = VArray ([Range],IOArray Int α) type instance Effect Shallow = IO
The implementations of alloc, read, and write use the corresponding operations on IOArrays. Inread andwrite, the indeximust be less than the length of the current slice. To convertiinto an index in the gloval array, we offset iby the range of the current slice; offset i rsoutputs theith index inrs.
instance HasArray Shallow where
alloc n a k = SExp $ \(ρ ∶∶ ECtx Shallow γ)→ do arr← IO.newArray (0,n-1) a
let v = VArray ([(0,n-1)],arr) x = (Proxy ∶∶ Proxy (Fresh γ)) runSExp (k $ var x) (addECtx x v ρ)
read i e = SExp $ \ρ → do
VArray (rs,arr) ← runSExp e ρ
if i < size rs then do let x = offset i rs a ← IO.readArray arr x
return $ VPair (VArray (rs,arr)) (VPut a) else error $ "Read "++ show i ++" out of bounds of "++ show rs
write i e a = SExp $ \ρ → do
VArray (rs,arr) ← runSExp e ρ
if i < size rs then do let x = offset i rs IO.writeArray arr x a return $ VArray (rs,arr)
else error $ "Write "++ show i ++" out of bounds "++ show rs
The implementation ofdeallocsimply returns a unit value—it does not explicitly deal- locate the array, which would be inappropriate when dropping partial slices. Furthermore, theIOlibrary does not expose a deallocation primitive for arrays. Thesizeoperation looks up the size of the underlying set of ranges.
dealloc e = SExp $ \ρ → runSExp e ρ ≫ return VUnit size e = SExp $ \ρ → do VArray (rs,arr) ← runSExp e ρ
let n = size rs
return $ VPair (VArray (rs,arr)) (VPut n)
The slice operation partitions the bounds of its input array according to its index, while combine evaluates its arguments and merges the resulting bounds. Neither actually
affects the underlying array.
slice i e = SExp $ \ρ→
do VArray (rs,arr)← runSExp e ρ if i < size rs
then let x = offset i rs (rs1,rs2) = partition x rs
in return $ VPair (VArray (rs1,arr)) (VArray (rs2,arr)) else error $ "Slice "++ show i ++" out of bounds of "++ show rs
combine e1 e2 = SExp $ \ρ → do let (ρ1,ρ2) = splitECtx ρ
VArray (rs1,arr) ← runSExp e1 ρ1 VArray (rs2,_) ← runSExp e2 ρ2 return $ VArray (union rs1 rs2, arr)
Alternatively, combine can be implemented concurrently by evaluating the two subex- pressions in separate threads. For very concurrent operations this could be more efficient, but in many cases it introduces too much overhead.
concurrentCombine e1 e2 = SExp $ \ρ → do let (ρ1,ρ2) = split ρ v1 ← newEmptyMVar v2 ← newEmptyMVar
forkIO $ runSExp e1 ρ1 >>= putMVar v1 forkIO $ runSExp e2 ρ2 >>= putMVar v2 VArray (rs1,arr) ← takeMVar v1 VArray (rs2,_) ← takeMVar v2 return $ VArray (union rs1 rs2, arr)
4.5.2 Arrays in the Lifted State Monad
The read, write, and size operations can be naturally lifted to the linear state monad transformer; recall that we write LStateT sig σ αforLinT sig (LState' σ) α.
readT ∶∶ HasArray exp⇒Int→ LStateT sig (Array k α) α writeT ∶∶ HasArray exp⇒Int→α→ LStateT sig (Array k α) ()
sizeT ∶∶ HasArray exp⇒LStateT sig (Array k α) Int
We can combine allocation and deallocation of an array into a singleLStateToperation.
allocT ∶∶ HasArray exp
⇒Int→α→ (forall k. LStateT exp (Array k α) β) → Lin exp β allocT n a op = suspend $ alloc n a $ \arr →
force op ∧
arr `letPair` \(arr,b) → dealloc arr `letUnit` b
Finally, we can derive a lifted operation that combines slicing and rejoining slices. The function sliceT takes an index and two state transformations on arrays. The resulting state transformation takes in an array, slices it around the given index, and applies the two state transformations to the two sub-arrays.
sliceT ∶∶ HasArray exp
⇒Int→ LStateT sig (Array k α) () → LStateT sig (Array k α) () → LStateT sig (Array k α) ()
sliceT i st1 st2 = Suspend . ˆλ ! \arr → slice i arr `letPair` \(arr1,arr2)→
forceT st1 ∧ arr1 `letPair` \(arr1,res)→ res >! \_ → forceT st2 ∧ arr2 `letPair` \(arr2,res)→ res >! \_ → combine arr1 arr2 ⊗ put ()
The boundiin sliceT iis inclusive—indexiwill be included in one of the two slices. In practice, we sometimes want a variant where an operation is applied to indices less than i and greater than i, but not equal to i itself. The function slice3 i opslices the array into three parts, and appliesopto the slice of indices less thani, and to the slice of indices greater than i, but not to index i itself. The bounds checking ensures that every slice is smaller than the original array.
-- slice3 i op applies op on indices < i and indices > i, does nothing at index i -- precondition: 0 ≤ i < length array
slice3 ∶∶ HasArray sig
slice3 i op =
do len ← sizeT
if len ≤ 2 then return ()
else if i == 0 then sliceT 1 (return ()) op else if i == len-1 then sliceT i op (return ())
else sliceT i op $ sliceT 1 (return ()) op
4.5.3 Quicksort
We will use the LStateTinterface to implement an in-place quicksort.
First, the operation swap i j swaps the indices iand j in the underlying array.
swap ∶∶ HasArray sig ⇒ Int → Int → LStateT sig (Array token α) () swap i j = do a ← readT i
b ← readT j writeT i b writeT j a
Quicksort relies on a helper functionpartition pivot (i,j)that, given a pivot value, swaps elements between indices i and j so that values less than pivot occur on the left- hand-side of the range, and values greater thanpivot occur on the right-hand-side of the range. It returns the index of the largest element in the range that is less thanpivot. The main quicksortalgorithm selects the initial pivot element as the value at index 0. It calls partition to obtain the middle of the array, and moves the pivot element there. Then, it recursively sorts the two subarrays surrounding the pivot element usingslice3.
The definitions of both partition and the main quicksort algorithm are shown in Figure 4.6.