This message shows a very simple implementation of Monadic Regions (for the particular case of IO and reading the file). The technique *statically* guarantees that neither a file handle nor any computation involving the handle can leak outside of the region that created it. Therefore, the handle can be safely closed (and its resources disposed of) whenever control leaves the corresponding 'withFile' block. Many handles can be open simultaneously, the type system enforces the proper nesting of their regions. The technique has no run-time overhead and induces no run-time errors. Unlike the previous implementation of monadic regions, only the basic extensions (higher-ranked types and one two-parameter type class) are used. No undecidable instances, no functional dependencies (let alone overlapping instances) are required. In fact, the implementation uses only one trivial typeclass and one trivial instance. Perhaps such an approach to File IO can be more widely used? It trivially generalizes to database IO and other kinds of IO. The motivation for monadic regions has been best explained by: Brandon Moore wrote on Haskell Cafe:
I'm assuming you understand how the type on runST and the STRef operations ensure that, even though you *can* smuggle out an STRef in the result from runST, you will never be able to use it again.
The idea was to do the equivalent thing with databases: use fancy types to ensure that handle can only be used inside to origination withDB or withCursor or whatever, and the bracketing function can release the resource on the way out, without worrying about it being used again.
Benjamin Franksen wrote:
I think this is an extremely good idea. I have been very frustrated with finalizers because of their limitations (can't rely on them being called at all), so have (reluctantly) been using the unsafe bracket version. Making it safe via a type system trick is really the way to go.
Let us start with the tests
{-# OPTIONS -fglasgow-exts #-}
module IORegionsTest where
import IORegions -- see below
test0 = withFile "/etc/motd" (const $ return True)
reader q = do c1 <- qGetChar q c2 <- qGetChar q return [c1,c2]
test1 = withFile "/etc/motd" reader test1r = runIOM test1 >>= print
Instead of handles, we have Qs -- marked handles. The are created by the function withFile and used similar to regular handles. A special IOM monad is a newtype away from the regular IO. The phantom type parameter of the IOM monad maintains the marks of the regions. *IORegionsTest> :t reader reader :: (Monad (IOM marks), IORegions.IN mark marks) => Q mark -> IOM marks [Char] the type of the reader shows that it takes a marked handle and yields a marked IO computation. The constraint IN assures that the computation must be marked with the mark of the handle. If we attempt to leak the handle: *> test2 = withFile "/tmp/i.hs" (\q -> return q) we get Inferred type is less polymorphic than expected Quantified type variable `mark' escapes In the second argument of `withFile', namely `(\ q -> return q)' The following is OK: we perform the computation and return its result:
test3 = withFile "/etc/motd" (\q -> (qGetChar q))
If we attempt to return the unperformed computation itself: *> test4 = withFile "/tmp/i.hs" (\q -> return (qGetChar q)) we get Could not deduce (IORegions.IN mark marks1) from the context (IORegions.IN mark marks) arising from use of `qGetChar' at IORegionsTest.h... As we said earlier, more than one handle can be at play at the same time:
reader2 q1 q2 = do c1 <- qGetChar q1 c2 <- qGetChar q2 return [c1,c2] test5 = withFile "/etc/motd" (\q1 -> withFile "/etc/motd" (\q2 -> reader2 q1 q2))
test5r = runIOM test5 >>= print
Incidentally, the inferred type of reader2 is *IORegionsTest> :t reader2 reader2 :: (Monad (IOM marks), IORegions.IN mark1 marks, IORegions.IN mark marks) => Q mark -> Q mark1 -> IOM marks [Char] Obviously, the resulting computation is marked with the marks of both argument handles. With two handles, we can actually return a handle -- provided we return an outermost handle from the innermost region (but not the other way around). For example, the following is wrong *> test6 = withFile "/etc/motd" *> (\q2 -> *> do *> q' <- withFile "/etc/motd" (\q -> return q) *> qGetChar q') but the following is OK:
test7 = withFile "/etc/motd" (\q2 -> do q' <- withFile "/etc/motd" (\q -> return q2) qGetChar q')
Ditto for the computation: The following is the improper leakage and leads to a type error: *> test8 = withFile "/etc/motd" *> (\q2 -> *> do *> a <- withFile "/etc/motd" (\q -> return (qGetChar q)) *> a) But the following is fine:
test9 = withFile "/etc/motd" (\q2 -> do a <- withFile "/etc/motd" (\q -> return (qGetChar q2)) a)
test9r = runIOM test9 >>= print
-- The file IORegions.hs follows. {-# OPTIONS -fglasgow-exts #-} -- Simple IO Regions module IORegions (runIOM, qGetChar, withFile, -- Only types are exported, not their data constructors! Q, IOM) where import Control.Exception import System.IO -- The marked IO monad. The data constructor is not exported. -- The type 'marks' is purely phantom (and is never instantiated, actually) newtype IOM marks a = IOM (IO a) deriving Monad unIOM (IOM x) = x -- The marked IO handle. The data constructor is not exported. newtype Q mark = Q Handle -- |IN mark marks| asserts that |mark| is a member of the mark set |marks| -- The mark set is really a set, and the best of all, it's typechecker -- that maintains it. We don't need to do anything at all. class IN a b instance IN () b -- Reading from a marked handle. The mark must be within the marks -- associated with the IOM monad qGetChar :: (IN mark marks) => Q mark -> IOM marks Char qGetChar (Q h) = IOM $ hGetChar h -- There must not be an operation to close a marked handle! -- withFile takes care of opening and closing (and disposing) of -- handles. -- Open the file, add the markset constraint for the duration of the body, -- and make sure the marked handle does not escape. -- The marked handle is closed on normal or abnormal exit from the -- body -- The type system guarantees the strong lexical scoping of -- withFile. That is, we can assuredly close all the handles after -- we leave withFile because we are assured that no computations with -- marked handles can occur after we leave withFile. withFile :: FilePath -> (forall mark. IN mark marks => Q mark -> IOM marks a) -> IOM marks a withFile filename proc = IOM( bracket (openFile filename ReadMode) (hClose) (\handle -> unIOM $ proc ((Q handle) :: Q ()))) -- Running the IOM monad runIOM :: (forall mark. IOM mark a) -> IO a runIOM = unIOM
I really like this Oleg... I think I will use this myself as much as possible in future... As my DB code already uses bracket notation and an opaque/abstract DB handle type, it should be quite easy to incorporate this, without changing the interface... Cool! Regards, Keean. oleg@pobox.com wrote:
This message shows a very simple implementation of Monadic Regions (for the particular case of IO and reading the file). The technique *statically* guarantees that neither a file handle nor any computation involving the handle can leak outside of the region that created it. Therefore, the handle can be safely closed (and its resources disposed of) whenever control leaves the corresponding 'withFile' block. Many handles can be open simultaneously, the type system enforces the proper nesting of their regions. The technique has no run-time overhead and induces no run-time errors. Unlike the previous implementation of monadic regions, only the basic extensions (higher-ranked types and one two-parameter type class) are used. No undecidable instances, no functional dependencies (let alone overlapping instances) are required. In fact, the implementation uses only one trivial typeclass and one trivial instance.
Perhaps such an approach to File IO can be more widely used? It trivially generalizes to database IO and other kinds of IO.
The motivation for monadic regions has been best explained by:
Brandon Moore wrote on Haskell Cafe:
I'm assuming you understand how the type on runST and the STRef operations ensure that, even though you *can* smuggle out an STRef in the result from runST, you will never be able to use it again.
The idea was to do the equivalent thing with databases: use fancy types to ensure that handle can only be used inside to origination withDB or withCursor or whatever, and the bracketing function can release the resource on the way out, without worrying about it being used again.
Benjamin Franksen wrote:
I think this is an extremely good idea. I have been very frustrated with finalizers because of their limitations (can't rely on them being called at all), so have (reluctantly) been using the unsafe bracket version. Making it safe via a type system trick is really the way to go.
Let us start with the tests
{-# OPTIONS -fglasgow-exts #-}
module IORegionsTest where
import IORegions -- see below
test0 = withFile "/etc/motd" (const $ return True)
reader q = do c1 <- qGetChar q c2 <- qGetChar q return [c1,c2]
test1 = withFile "/etc/motd" reader test1r = runIOM test1 >>= print
Instead of handles, we have Qs -- marked handles. The are created by the function withFile and used similar to regular handles. A special IOM monad is a newtype away from the regular IO. The phantom type parameter of the IOM monad maintains the marks of the regions.
*IORegionsTest> :t reader reader :: (Monad (IOM marks), IORegions.IN mark marks) => Q mark -> IOM marks [Char]
the type of the reader shows that it takes a marked handle and yields a marked IO computation. The constraint IN assures that the computation must be marked with the mark of the handle.
If we attempt to leak the handle: *> test2 = withFile "/tmp/i.hs" (\q -> return q)
we get Inferred type is less polymorphic than expected Quantified type variable `mark' escapes In the second argument of `withFile', namely `(\ q -> return q)'
The following is OK: we perform the computation and return its result:
test3 = withFile "/etc/motd" (\q -> (qGetChar q))
If we attempt to return the unperformed computation itself: *> test4 = withFile "/tmp/i.hs" (\q -> return (qGetChar q))
we get Could not deduce (IORegions.IN mark marks1) from the context (IORegions.IN mark marks) arising from use of `qGetChar' at IORegionsTest.h...
As we said earlier, more than one handle can be at play at the same time:
reader2 q1 q2 = do c1 <- qGetChar q1 c2 <- qGetChar q2 return [c1,c2] test5 = withFile "/etc/motd" (\q1 -> withFile "/etc/motd" (\q2 -> reader2 q1 q2))
test5r = runIOM test5 >>= print
Incidentally, the inferred type of reader2 is
*IORegionsTest> :t reader2 reader2 :: (Monad (IOM marks), IORegions.IN mark1 marks, IORegions.IN mark marks) => Q mark -> Q mark1 -> IOM marks [Char]
Obviously, the resulting computation is marked with the marks of both argument handles.
With two handles, we can actually return a handle -- provided we return an outermost handle from the innermost region (but not the other way around). For example, the following is wrong
*> test6 = withFile "/etc/motd" *> (\q2 -> *> do *> q' <- withFile "/etc/motd" (\q -> return q) *> qGetChar q')
but the following is OK:
test7 = withFile "/etc/motd" (\q2 -> do q' <- withFile "/etc/motd" (\q -> return q2) qGetChar q')
Ditto for the computation:
The following is the improper leakage and leads to a type error:
*> test8 = withFile "/etc/motd" *> (\q2 -> *> do *> a <- withFile "/etc/motd" (\q -> return (qGetChar q)) *> a)
But the following is fine:
test9 = withFile "/etc/motd" (\q2 -> do a <- withFile "/etc/motd" (\q -> return (qGetChar q2)) a)
test9r = runIOM test9 >>= print
-- The file IORegions.hs follows.
{-# OPTIONS -fglasgow-exts #-}
-- Simple IO Regions
module IORegions (runIOM, qGetChar, withFile, -- Only types are exported, not their data constructors! Q, IOM) where
import Control.Exception import System.IO
-- The marked IO monad. The data constructor is not exported. -- The type 'marks' is purely phantom (and is never instantiated, actually) newtype IOM marks a = IOM (IO a) deriving Monad unIOM (IOM x) = x
-- The marked IO handle. The data constructor is not exported. newtype Q mark = Q Handle
-- |IN mark marks| asserts that |mark| is a member of the mark set |marks| -- The mark set is really a set, and the best of all, it's typechecker -- that maintains it. We don't need to do anything at all. class IN a b instance IN () b
-- Reading from a marked handle. The mark must be within the marks -- associated with the IOM monad qGetChar :: (IN mark marks) => Q mark -> IOM marks Char qGetChar (Q h) = IOM $ hGetChar h
-- There must not be an operation to close a marked handle! -- withFile takes care of opening and closing (and disposing) of -- handles.
-- Open the file, add the markset constraint for the duration of the body, -- and make sure the marked handle does not escape. -- The marked handle is closed on normal or abnormal exit from the -- body -- The type system guarantees the strong lexical scoping of -- withFile. That is, we can assuredly close all the handles after -- we leave withFile because we are assured that no computations with -- marked handles can occur after we leave withFile.
withFile :: FilePath -> (forall mark. IN mark marks => Q mark -> IOM marks a) -> IOM marks a withFile filename proc = IOM( bracket (openFile filename ReadMode) (hClose) (\handle -> unIOM $ proc ((Q handle) :: Q ())))
-- Running the IOM monad runIOM :: (forall mark. IOM mark a) -> IO a runIOM = unIOM
_______________________________________________ Haskell mailing list Haskell@haskell.org http://www.haskell.org/mailman/listinfo/haskell
I tweaked the IORegions code using some other ideas from the thread. The "IOM marks a" monad which wrapped (IO a) is now "IOQ marks m a" which wraps "m a". So it is a MonadTrans and if m is MonadIO then so is "IOQ marks m". qGetChar was just a demo, and has been replaced by liftH, liftH2, and liftH3 which should cover all the System.IO hFoo functions. But bracket does not know what MonadIO is, so the withFile* functions are no as general as possible. Eventually these could be stuck on the wiki. -- Chris
{-# OPTIONS -fglasgow-exts #-}
This is a tweaked version from oleg. I think all the "should not compile" tests still correctly create error messages when compiled.
module IORegionsTest where
import IORegions3 -- see below import System.IO
test0 = withFile "/etc/motd" (const $ return True)
With IORegions3, liftH,liftH2,liftH3 are exported, and withFile* functions. This lets us promote every h* functions ourselves. A fseparate sub-module, perhaps IORegions.IO, could have all these trivial lifting already written and named:
qGetChar q = liftH hGetChar q
reader q = do c1 <- qGetChar q c2 <- qGetChar q return [c1,c2]
test1 = withFile "/etc/motd" reader test1r = runIOQ test1 >>= print
Instead of handles, we have Qs -- marked handles. The are created by the function withFile and used similar to regular handles. A special IOQ monad is a newtype away from the regular IO. The phantom type parameter of the IOQ monad maintains the marks of the regions. *IORegionsTest> :t reader reader :: (Monad (IOQ marks), IORegions.IN mark marks) => Q mark -> IOQ marks [Char] the type of the reader shows that it takes a marked handle and yields a marked IO computation. The constraint IN assures that the computation must be marked with the mark of the handle. If we attempt to leak the handle: *> test2 = withFile "/tmp/i.hs" (\q -> return q) we get Inferred type is less polymorphic than expected Quantified type variable `mark' escapes In the second argument of `withFile', namely `(\ q -> return q)' The following is OK: we perform the computation and return its result:
test3 = withFile "/etc/motd" (\q -> (qGetChar q))
test3r = runIOQ test3 >>= print
If we attempt to return the unperformed computation itself: *> test4 = withFile "/tmp/i.hs" (\q -> return (qGetChar q)) we get Could not deduce (IORegions.IN mark marks1) from the context (IORegions.IN mark marks) arising from use of `qGetChar' at IORegionsTest.h... As we said earlier, more than one handle can be at play at the same time:
reader2 q1 q2 = do c1 <- qGetChar q1 c2 <- qGetChar q2 return [c1,c2] test5 = withFile "/etc/motd" (\q1 -> withFile "/etc/motd" (\q2 -> reader2 q1 q2))
test5r = runIOQ test5 >>= print
Incidentally, the inferred type of reader2 is *IORegionsTest> :t reader2 reader2 :: (Monad (IOQ marks), IORegions.IN mark1 marks, IORegions.IN mark marks) => Q mark -> Q mark1 -> IOQ marks [Char] Obviously, the resulting computation is marked with the marks of both argument handles. With two handles, we can actually return a handle -- provided we return an outermost handle from the innermost region (but not the other way around). For example, the following is wrong *> test6 = withFile "/etc/motd" *> (\q2 -> *> do *> q' <- withFile "/etc/motd" (\q -> return q) *> qGetChar q') but the following is OK:
test7 = withFile "/etc/motd" (\q2 -> do q' <- withFile "/etc/motd" (\q -> return q2) qGetChar q')
test7r = runIOQ test7 >>= print
Ditto for the computation: The following is the improper leakage and leads to a type error: *> test8 = withFile "/etc/motd" *> (\q2 -> *> do *> a <- withFile "/etc/motd" (\q -> return (qGetChar q)) *> a) But the following is fine:
test9 = withFile "/etc/motd" (\q2 -> do a <- withFile "/etc/motd" (\q -> return (qGetChar q2)) a)
test9r = runIOQ test9 >>= print
All the test runners:
tests = [test1r,test3r,test5r,test7r,test9r]
runTests = sequence tests
{-# OPTIONS -fglasgow-exts #-} {- Version 2006-01-19 by Chris Kuklewicz This is a tweaked version from oleg, using Benjamin's phantom Mark instead of (). And the monad was generalized from IO to any monad, allowing it to be a MonadTrans, and if it is over MonadIO (e.g. IO) then IOQ is a MonadIO as well. Instead of qGetChar, I made the three liftH* functions which can be applied to any handle h* function in System.IO (possibly in a sub-module of this one). But since there is no "unlift" operation, the withFile bracket code requires the procedure to be "IOQ marks IO a" over the IO monad. Error handling will run into similar restrictions. I did generalize the withFile to take a FileMode, and provide IO and MonadIO instances (which helps with type inference when using this module). The IOM monad was renamed to IOQ since I altered its kind. -} module IORegions4 (runIOQ, withFile, withFileIO, withFileMode, liftH, liftH2, liftH3, -- Only types are exported, not their data constructors! QHandle, IOQ) where import Control.Monad import Control.Monad.Trans import Control.Exception import System.IO -- The marked monad transformer. The data constructor is not exported. -- The type 'marks' is purely phantom (and is never instantiated, actually) newtype IOQ marks m a = IOQ (m a) deriving (Monad) instance MonadTrans (IOQ marks) where lift = IOQ instance (MonadIO m) => MonadIO (IOQ marks m) where liftIO = IOQ . liftIO -- This an unsafe operation to export unIOQ :: IOQ mark m a -> m a unIOQ (IOQ x) = x -- Running the IOQ monad, type is different than unIOQ and safe to export runIOQ :: (forall mark. IOQ mark m a) -> m a runIOQ = unIOQ -- The marked IO handle. The data constructor is not exported. newtype QHandle mark = Q Handle -- |IN mark marks| asserts that |mark| is a member of the mark set |marks| -- The mark set is really a set, and the best of all, it's typechecker -- that maintains it. We don't need to do anything at all. class IN a b -- |Mark| is the only (phantom) type that can be an instance of IN data Mark instance IN Mark -- Wrap a handle as a marked handle mark :: Handle -> QHandle Mark mark h = Q h -- Operating on a marked handle. The mark must be within the marks -- associated with the IOQ monad liftH :: (MonadIO m,IN mark marks) => (Handle -> IO a) -> QHandle mark -> IOQ marks m a liftH op (Q h) = IOQ (liftIO (op h)) liftH2 :: (MonadIO m,IN mark marks) => (Handle -> x2 -> IO a) -> QHandle mark -> x2 -> IOQ marks m a liftH2 op (Q h) x2 = IOQ (liftIO (op h x2)) liftH3 :: (MonadIO m,IN mark marks) => (Handle -> x2 -> x3 -> IO a) -> QHandle mark -> x2 -> x3 -> IOQ marks m a liftH3 op (Q h) x2 x3 = IOQ (liftIO (op h x2 x3)) --qGetChar :: (MonadIO io, IN mark marks) => Q mark -> IOQ marks io Char -- is inferred qGetChar q = liftH hGetChar q -- There must not be an operation to close a marked handle! withFile -- takes care of opening and closing (and disposing) of handles. -- Open the file, add the markset constraint for the duration of the -- body, and make sure the marked handle does not escape. -- The marked handle is closed on normal or abnormal exit from the -- body -- The type system guarantees the strong lexical scoping of -- withFile. That is, we can assuredly close all the handles after we -- leave withFile because we are assured that no computations with -- marked handles can occur after we leave withFile. withFile :: FilePath -> (forall mark. IN mark marks => QHandle mark -> IOQ marks IO a) -> IOQ marks IO a withFile filename proc = IOQ (bracket (openFile filename ReadMode) (hClose) (\handle -> unIOQ $ proc $ mark handle)) withFileIO :: (MonadIO io) => FilePath -> (forall mark. IN mark marks => QHandle mark -> IOQ marks IO a) -> IOQ marks io a withFileIO filename proc = IOQ $ liftIO (bracket (openFile filename ReadMode) (hClose) (\handle -> unIOQ $ proc $ mark handle)) withFileMode :: (MonadIO io) => FilePath -> IOMode -> (forall mark. IN mark marks => QHandle mark -> IOQ marks IO a) -> IOQ marks io a withFileMode filename mode proc = IOQ $ liftIO (bracket (openFile filename mode) (hClose) (\handle -> unIOQ $ proc $ mark handle)) withFileModeIO :: FilePath -> IOMode -> (forall mark. IN mark marks => QHandle mark -> IOQ marks IO a) -> IOQ marks IO a withFileModeIO filename mode proc = IOQ (bracket (openFile filename mode) (hClose) (\handle -> unIOQ $ proc $ mark handle))
Simon Peyton-Jones wrote:
Previously I've thought of using a nested tuple type (m1, (m2, (m3 ())))
That was my thought too. But then we need the comparison operation on those 'm' (which are actually type eigen-variables). Not that it can't be done (it can, and several examples prove that). But it requires a little bit too many extensions. It appears there is a way to move the whole burden of maintaining the set of types (which become the set of constraints) to the typechecker. That is, to you...
Why do you need the instance IN () b ?
Don't we need at least one instance in a class so that the typechecker could resolve the constraint? Dominic Steinitz wrote:
Can someone give an explanation of how the marks get built up?
Suppose we have a class TypeEq a b so that the constraint TypeEq holds whenever a and b are the same. Then the type Int and the type TypeEq a Int => a are kind of equivalent, right? The HList library plays many tricks like that. The idea is that we can replace a definite type with a type variable subject to some constraint (which we can fix later on). In some respects, constraints are more convenient: they float, their order does not matter, their duplicates are automatically eliminated. Just what we need to build a set... It ``follows'' then that instead of building the union of types, we can build the union of constraints. The latter operation is trivial: the typechecker does that all the time. If we restrict the scope of the type variable by quantification, the scope of the corresponding constraint is likewise restricted. The quantification also builds eigen-variables (which are distinct from anything else) -- so we get 'gensym' on the type level for free. That is all there is to it... Chris Kuklewicz wrote:
I tweaked the IORegions code using some other ideas from the thread.
qGetChar was just a demo, and has been replaced by liftH, liftH2, and liftH3 which should cover all the System.IO hFoo functions.
As Andrew Pimlott has just pointed out on Haskell cafe, liftH functions should not be exported! The functions within the IORegions module have unrestricted access to the naked handle. They have to be careful and trusted: -- do not leak the unwrapped handle -- do not close the handle (if the handle really needs to be closed, one better just throw an exception or error) Because liftH accepts an _arbitrary_ function on handles, it essentially extends the trust to anyone outside of IORegions -- which defeats the latter's purpose. True, liftH functions are convenient -- but they must be used _inside_ of the module, to build qGetChar, qPutChar, etc. functions. It is the latter that can be exported. The idea of regions is that not all operations on handles are `safe'. Thus we need a way to specifically enumerate which are those primitive operations that safe. Enumerating lifted versions of getChar, putChar, etc. in the export list of IORegions is that enumeration. I'm afraid we have a circumstance where genericity is not a virtue.
It seems the "Simple IO Regions" are insecure:
{-# OPTIONS -fglasgow-exts #-}
module BreakIORegions where
import IORegions import Control.Monad
In particular, we can build actions involving a handle inside its scope, and execute them outside. Look closely at the type error from Oleg's test 8: IORegionsTest.lhs:36:53: Could not deduce (IORegions.IN mark marks1) from the context (IORegions.IN mark marks) arising from use of `qGetChar' at IORegionsTest.lhs:36:53-60 Probable fix: add (IORegions.IN mark marks1) to the expected type of an expression In the first argument of `return', namely `(qGetChar q)' In a lambda abstraction: \ q -> return (qGetChar q) In the second argument of `withFile', namely `(\ q -> return (qGetChar q))' The problem isn't anything about which marks are present - it's because the application qGetChar q somehow ended up with a different type variable "marks1" than the marks variable used in this instance of IOM. I don't understand why trying to use the returned action later on isn't enough to force the types to unify, but maybe GHC is a little too eager to figure out whether class constraints are satisfied. Anyway, we can leak an action if we give it a little help in unifying the types, here with lexically scope type variables
test1 = let (body :: IOM marks Char) = do a <- withFile "/etc/motd" (\q -> return (qGetChar q) :: IOM marks (IOM marks Char)) a in body test1r = runIOM test1 >>= print
Or more compactly with join:
test = runIOM (join (withFile "/etc/motd" (return . qGetChar))) >>= print
Brandon Moore
After an experiment with the simple IO regions, one can conclude that the only implementation of the regions has to be complex. It seems however that there is a Haskell98 implementation of IO regions. The solution involves no higher-ranked types, and works both in GHC and Hugs. The idea is trivial: given
newtype Q = Q Handle newtype IOM a = IOM (IO a) qGetChar :: Q -> IOM Char withFile :: FilePath -> (Q -> IOM a) -> IOM a
we wish to assure that the handle Q never escapes, neither explicitly nor implicitly. If the handle is incorporated into the result of |withFile|, the type |Q| must be mentioned in that result. The only computation that eliminates type |Q| is qGetChar; but the latter introduces the type |IOM|. It follows then that if we make sure that neither |IOM| nor |Q| appear in the type of the values produced by |withFile|, our goal is achieved. We can define our own type introspection typeclasses to check that a particular type has no occurrences of |Q| or |IOM|. Luckily, the Haskell standard library already has what we need: |Typeable|. There are instances of |Typeable| defined for all standard types, and there are ways for the users to extend that set. So, we just need to add the constraint |Typeable| to the type of |withFile|, and we have solved our problem. That solution, although simple, has an inconvenience: to prevent the user from defining their own Typeable instances for |Q| and |IOM|, we have to hide the latter type constructors. That makes it impossible to write explicit signatures for the IO-region-related functions. The enclosed solution offers a simple refinement, which avoids that inconvenience. It should be pointed out that we statically prevent _any_ handle from escaping |withFile|, regardless of its nesting label. It seems that this restriction is acceptable; I couldn't think of any compelling example why would one need to be able to return the handle of the outer region (given that handle is already available in the outer region). If we attempt to leak the handle
test2 = withFile "/etc/motd" (\q -> return q)
we get the type error: ERROR "IORegions98Test.hs":22 - Unresolved top-level overloading *** Binding : test2 *** Outstanding context : Typeable (Q Z) in Hugs or IORegions98Test.hs:22:8: No instances for (Data.Typeable.Typeable1 Q, Data.Typeable.Typeable IORegions98.Z) arising from use of `withFile' Probable fix: add an instance declaration for (Data.Typeable.Typeable1 Q, Data.Typeable.Typeable IORegions98.Z) In the definition of `test2': test2 = withFile "/etc/motd" (\ q -> return q) in GHC. It is easy to see that even a very trusting user cannot follow the GHC advice and add the instance of Typeable for the type |Z| because the latter is not available to the user of the library. We get the similar errors for
test4 = withFile "/etc/motd" (\q -> return (qGetChar q))
ERROR "IORegions98Test.hs":26 - Unresolved top-level overloading *** Binding : test4 *** Outstanding context : Typeable (IOM Z Char) and for Brandon Moore's test:
test4' () = join $ withFile "/etc/motd" (\q -> return (qGetChar q))
ERROR "IORegions98Test.hs":78 - Instance of Typeable (IOM Z Char) required for definition of test4' The regular tests, e.g.,
reader2 :: Q mark -> Q mark -> IOM mark String reader2 q1 q2 = do c1 <- qGetChar q1 c2 <- qGetChar q2 return [c1,c2]
test5 = withFile "/etc/motd" (\q1 -> withFile "/etc/motd" (\q2 -> reader2 q1 q2))
test5r = runIOM test5 >>= print
pass as before. We can give the explicit signature to our functions, if we wish. ---- file IORegions98.hs {-- Haskell98! --} -- Even simpler IO Regions, in Haskell98 module IORegions98 (runIOM, qGetChar, withFile, -- Only types are exported, not their data constructors Q, IOM ) where import Control.Exception import System.IO import Data.Typeable -- The marked IO monad. The data constructor is not exported. -- Monad derivation is not supported in Haskell98, we have to do it by hand -- newtype IOM marks a = IOM (IO a) deriving Monad newtype IOM marks a = IOM (IO a) unIOM (IOM x) = x instance Monad (IOM marks) where return = IOM . return m >>= f = IOM (unIOM m >>= unIOM . f) -- The marked IO handle. The data constructor is not exported. newtype Q mark = Q Handle -- The only mark that we use here. Even its type is not exported. -- It is important that Z must NOT be the instance of Typeable data Z = Zunused -- Reading from a marked handle. The mark must be within the marks -- associated with the IOM monad. We only use one mark: -- since we prevent escaping of the Q handle from the inner scope, -- the only handles in scope must be the ones created in the current -- or outer regions. qGetChar :: Q mark -> IOM mark Char qGetChar (Q h) = IOM $ hGetChar h -- There must not be an operation to close a marked handle! -- withFile takes care of opening and closing (and disposing) of -- handles. -- If one really must close the handle prematurely, throw any exception. -- When the exception reaches withFile, the handle will be closed. -- If the handle is to be closed, the rest of its region becomes useless -- anyway. -- Open the file, and make sure the marked handle does not escape. -- The marked handle is closed on normal or abnormal exit from the -- body -- The type system guarantees the strong lexical scoping of -- withFile. That is, we can assuredly close all the handles after -- we leave withFile because we are assured that no computations with -- marked handles can occur after we leave withFile. -- Because Z is not the instance of Typeable, and because Z is completely -- hidden (so it cannot be made the instance of Typeable), neither -- |Q Z| nor |IOM Z| may appear in |a|. withFile :: Typeable a => FilePath -> (Q Z -> IOM Z a) -> IOM Z a withFile filename proc = IOM( bracket (openFile filename ReadMode) (hClose) (\handle -> unIOM $ proc (Q handle))) -- Running the IOM monad runIOM :: IOM Z a -> IO a runIOM = unIOM --- file IORegions98Test.hs {-- Haskell98! --} -- Simple IO Regions. Tests module IORegions98Test where import IORegions98 import Monad reader q = do c1 <- qGetChar q c2 <- qGetChar q return [c1,c2] test0 = withFile "/etc/motd" (const $ return True) test1 = withFile "/etc/motd" reader test1r = runIOM test1 >>= print -- An attempt to leak a handle -- test2 = withFile "/etc/motd" (\q -> return q) test3 = withFile "/etc/motd" (\q -> (qGetChar q)) -- An attempt to leak a computation that uses the handle -- test4 = withFile "/etc/motd" (\q -> return (qGetChar q)) -- Dealing with two handles -- Now we add the type signature, just to show that we can do that reader2 :: Q mark -> Q mark -> IOM mark String reader2 q1 q2 = do c1 <- qGetChar q1 c2 <- qGetChar q2 return [c1,c2] test5 = withFile "/etc/motd" (\q1 -> withFile "/etc/motd" (\q2 -> reader2 q1 q2)) test5r = runIOM test5 >>= print -- An attempt to leak the handles. {- test6 = withFile "/etc/motd" (\q2 -> do q' <- withFile "/etc/motd" (\q -> return q) qGetChar q') test7 = withFile "/etc/motd" (\q2 -> do q' <- withFile "/etc/motd" (\q -> return q2) qGetChar q') -} -- An attempt to leak computations involving handles {- test8 = withFile "/etc/motd" (\q2 -> do a <- withFile "/etc/motd" (\q -> return (qGetChar q)) a) test9 = withFile "/etc/motd" (\q2 -> do a <- withFile "/etc/motd" (\q -> return (qGetChar q2)) a) test9r = runIOM test9 >>= print -} -- An attempt to leak a computation that uses the handle, by Brandon Moore --test4' () = join $ withFile "/etc/motd" (\q -> return (qGetChar q)) --- file IORegions98Test1.hs {-# OPTIONS -fglasgow-exts #-} -- Simple IO Regions. Tests -- Attempt to break the safety guarantees by using encapsulated polymorphic -- types module IORegions98Test1 where import IORegions98 import Monad import Data.Typeable data W1 a = W1 (forall marks. IOM marks a) data W2 = W2 (forall marks. Q marks) data W3 a = W3 (forall marks. Q marks -> IOM marks a) instance Typeable1 W1 -- Hugs likes the commented-out instance --instance Typeable a => Typeable (W3 a) instance Typeable1 W3 instance Typeable W2 --test1 = withFile "/etc/motd" (\q -> return $ W2 q) --test2 = withFile "/etc/motd" (\q -> return $ W1 (qGetChar q)) test3 = withFile "/etc/motd" (\q -> return $ W3 (qGetChar)) --test4 = withFile "/etc/motd" (\q -> return $ W3 (\q1 -> qGetChar q))
Hi Oleg This approach has the same flaw as your "Simple IO Regions" code. The typing prevents you from constructing actions involving a handle outside the region of the handle, but not from constructing actions in the region and using them outside. Without the rank-2 types we can use runIOM, and should to take advantage of the Typeable1 IO instance. --saving this message as IORegionsTest98Fail.lhs
module IORegions98TestFail where import IORegions98 import Monad
testMinus1 = join $ runIOM $ withFile "/etc/motd" $ return . runIOM . qGetChar
Which fails like this: ___ ___ _ / _ \ /\ /\/ __(_) / /_\// /_/ / / | | GHC Interactive, version 6.4, for Haskell 98. / /_\\/ __ / /___| | http://www.haskell.org/ghc/ \____/\/ /_/\____/|_| Type :? for help. Loading package base-1.0 ... linking ... done. Compiling IORegions98 ( ./IORegions98.hs, interpreted ) Compiling IORegions98TestFail ( IORegions98TestFail.lhs, interpreted ) Ok, modules loaded: IORegions98TestFail, IORegions98. *IORegions98TestFail> testMinus1 Loading package haskell98-1.0 ... linking ... done. *** Exception: /etc/motd: hGetChar: illegal operation (handle is closed) I think something like the Simple IO Regions code can be made to work with a more complicated representation of the marks set. I'll see if I can post a system of my own in a few days, rather than just test cases for yours. Brandon
testMinus1 = join $ runIOM $ withFile "/etc/motd" $ return . runIOM . qGetChar
Isn't this the same situation we have in Haskell98 with respect to the regular IO? The safety of IO depends on the fact that the IO type constructor cannot be eliminated. Hence the 'main' trick, hence the only function that can eliminate IO is named 'unsafe'. If we wish for the safe runIOM, we have to enforce the same guarantees as we have in the ST monad regarding runST. That of course, cannot be done in Haskell98 (although the resulting library can be used in Haskell98 code). It should be emphasized that the Typeable constraint has reduced the problem of 'region nesting' to the regular problem of the 'linearity' of computations -- which is already solved in ST monad. We can add that pervasive 's' type parameter to our Q and IOM types. However, the simpler approach is just to use our 'mark' as that 's' parameter. So, the only changes are changes in the type signatures: withFile :: Typeable a => FilePath -> (Q mark -> IOM mark a) -> IOM mark a runIOM :: (forall mark. IOM mark a) -> IO a (and adding -fglasgow-exts flag, which is not-viral: the code that uses IO regions can remain Haskell98). Luckily, polymoprhic types, such as |IOM mark a| must be with respect to |mark|, cannot be instances of Typeable, so our safety constraint is automatically satisfied. I knew the polymorphic restriction on Typeable must be good for something.
participants (4)
-
Brandon Moore -
Chris Kuklewicz -
Keean Schupke -
oleg@pobox.com