Others have mentioned closures; there are also a few other patterns that can do similar things.
One is "ad-hoc polymorphism", which lets us associate values with types. This is essentially the same as implementing an interface in languages like Java, except we don't need to modify any existing definitions (e.g. we don't need to edit a class to say "extends Foo"). This is useful for picking between pre-defined sets of values, e.g. for your example of logger and base dir we can define an interface like 'Environment' with methods returning a base dir and a logger; then we can define a 'Production' type which implements this interface by providing the real logger and base dir, a 'Test' type which returns a temp dir and stdout logger, a 'Dev' type whose logger keeps verbose debug messages, etc. We can write our code in terms of a generic Environment, then in our 'main' function we specify that we're using 'Production'; in our test suite we specify that it's 'Test'; when debugging we can specify 'Dev'.
Another approach is called 'Reader', and it's based around the following type and helper functions (in Haskell syntax):
data Reader a b = R (a -> b)
read :: Reader a a
read = R (\x -> x)
runReader :: Reader a b -> a -> b
runReader (R f) x = f x
Or in Scala:
final class Reader[A, B](f: A => B) {
def run(x: A): B = f(x)
}
final object Reader {
def read[A]: Reader[A, A] = new Reader(x => x)
}
Note that this type literally just contains functions from some type A to another type B. The function 'read' is a wrapped-up identity function (returning its argument unchanged), the 'run' function just applies a wrapped-up function to an argument.
This gets interesting if we do two things. First we can specialise the input type (a or A), e.g. in Haskell:
type Application t = Reader (Path, Logger)
Or in Scala:
type Application[T] = Reader[(Path, Logger), T]
The type 'Application[T]' represents functions from a Path+Logger pair to a T.
Next we can define some functions for manipulating Reader values:
-- Applies a function f to a Reader's result; AKA function composition
map :: (a -> b) -> Reader t a -> Reader t b
map f (R r) = R (\x -> f (r x))
-- Wrap up a value into a Reader, by accepting an argument and ignoring it
wrap :: a -> Reader t a
wrap x = R (\y -> x)
-- Combine two Readers, so they're given the same argument and their return values are paired
product :: Reader t a -> Reader t b -> Reader t (a, b)
product (R f) (R g) = R (\x -> (f x, g x))
-- "Unwrap" nested Readers, by passing the argument into the result
join :: Reader t (Reader t a) -> Reader t a
join (R f) = R (\x -> runReader (f x) x)
Together these functions let us write arbitrary functions "inside" this 'Application' type. For example:
-- The 'fst' function gets the first element of a pair
baseDir :: Application Path
baseDir = map fst read
-- The 'snd' function gets the second element of a pair
logger :: Application Logger
logger :: map snd read
-- Use the logger to log the given string (via some 'logWith' function)
log :: String -> Application ()
log msg = map (\l -> logWith l msg) logger
-- Read the contents of a given file from the base dir
openFile :: Filename -> Application String
openFile f = map (\d -> readContents (d + "/" + f)) baseDir
-- Log the contents of a given file from the base dir
logContents :: Filename -> Application ()
logContents f = join (map log (openFile f))
Note how we can only access the base dir and logger from 'inside' the Application type; i.e. we can get an 'Application Logger', but we can never just get a 'Logger'. That's because the code doesn't actually define a logger or base dir anywhere; any value 'Application T' is really just a function '(Logger, Path) -> T'. To extract a result, we need to give it to 'runReader', which (in the case of Application) also needs to be given a '(Logger, Path)' pair.
Using these functions directly can be a bit tedious. Some FP languages have special syntax which makes it nicer, e.g. in Haskell we can write things like:
do x <- foo
y <- bar x
baz x y
The Scala equivalent is:
for {
x <- foo
y <- bar(x)
z <- baz(x, y)
} yield z
This API is a combination of Functor (map), Applicative (wrap and product) and Monad (join). Hence people sometimes call this "the Reader monad"; but don't let that scare you!
Thanks, I do know some Haskell, but I never dug deep enough into it to fully understand some of the concepts such as Reader. It seems to at least approximate the concept quite well.
Still - I don't know whether it's just due to unfamiliarity, but it seems to me that the simplicity and readability of the "OOP" equivalent (not requiring you to understand the semantics of "map" and "join" in the context of Reader), are a bit better. Also - OOP languages allow for dynamic dispatch and if I'm not totally mistaken (but I might), Haskell at least doesn't allow that unless you start adding language extensions, so you can't even make something like `Logger` a type class. I guess that would work in Scala, though, but then again, Scala also supports my original "OOP" approach anyway.
The ad-hoc polymorphism idea doesn't really solve the issue, I think, because even if you bundle up all the values into an "Environment" variable, you still have to pass it around from function to function.
Others have mentioned closures, too. I'm not sure if they mean something like:
data UserRepository = Repository { load :: UserID -> IO User, save :: User -> IO () }
mkUserRepository :: Directory -> Logger -> UserRepository
mkUserRepository baseDir logger = UserRepository { load = load, save = save }
where load = ...
save = ...
I mean, I guess that works somehow, but now we've made it possible to create user repositories with weird semantics (I know you hide the default data type constructor from other modules etc., but still). It also makes the implementation weirdly separated from the data type. And it makes me wonder... where would I even add the documentation for what the functions do. Having top-level functions certainly seems preferable to me.
My point is, I think nothing would stop a PFP language from theoretically allowing to have some sort of "module scope" where we pass some dependencies to the module that can then be re-used internally. The module could even be represented internally as a data type + a constructor function, but it would allow you to use top-level functions that can use those arguments. It shouldn't break any of the correctness / purity guarantees.
E.g. (just a quick sketch):
-- UserRepository is simultaneously a module, a constructor function and a data type
module (logger :: Logger, baseDir :: File) => UserRepository where
-- ad-hoc syntax to show that UserRepository is
-- an argument to the function at call-site, but
-- it's looked up "implicitly" from the context
-- within the definition
load :: (UserRepository) => ID -> IO User
load id = loadFrom baseDir id
where loadFrom = ...
save :: (UserRepository) => User -> IO ()
save = ...
and then
import UserRepository (load, save)
main = do
let baseDir = File "/some/Directory"
let logger = Logger "/some/Directory/logfile.log"
let repo = UserRepository baseDir logger
user <- load repo 1
...
Maybe PFP-ers wouldn't find that useful, but I think I would find it very readable while essentially only amounting to "syntactic sugar" without having to break language semantics. I don't want to claim that Haskell should do this, though. :)
I also didn't want to claim that languages like Haskell don't have any way of doing this, just that, from a readability / simplicity point of view I find this "OOP" feature to be very useful and I haven't yet seen anything in the Haskell world that seems comparable _to me_ ergonomics-wise.
I think what you are asking for are first-class parameterised modules, which is certainly a feature that OOP has that many FP languages don't. It does exist in some FP languages though, for example OCaml and the 1ML paper and language.
Most FP languages are already incredibly good for the amount of funding and development they have received. They could be so much better if industry / US big-tech actually started investing in them or the ideas behind them.
One is "ad-hoc polymorphism", which lets us associate values with types. This is essentially the same as implementing an interface in languages like Java, except we don't need to modify any existing definitions (e.g. we don't need to edit a class to say "extends Foo"). This is useful for picking between pre-defined sets of values, e.g. for your example of logger and base dir we can define an interface like 'Environment' with methods returning a base dir and a logger; then we can define a 'Production' type which implements this interface by providing the real logger and base dir, a 'Test' type which returns a temp dir and stdout logger, a 'Dev' type whose logger keeps verbose debug messages, etc. We can write our code in terms of a generic Environment, then in our 'main' function we specify that we're using 'Production'; in our test suite we specify that it's 'Test'; when debugging we can specify 'Dev'.
Another approach is called 'Reader', and it's based around the following type and helper functions (in Haskell syntax):
Or in Scala: Note that this type literally just contains functions from some type A to another type B. The function 'read' is a wrapped-up identity function (returning its argument unchanged), the 'run' function just applies a wrapped-up function to an argument.This gets interesting if we do two things. First we can specialise the input type (a or A), e.g. in Haskell:
Or in Scala: The type 'Application[T]' represents functions from a Path+Logger pair to a T.Next we can define some functions for manipulating Reader values:
Together these functions let us write arbitrary functions "inside" this 'Application' type. For example: Note how we can only access the base dir and logger from 'inside' the Application type; i.e. we can get an 'Application Logger', but we can never just get a 'Logger'. That's because the code doesn't actually define a logger or base dir anywhere; any value 'Application T' is really just a function '(Logger, Path) -> T'. To extract a result, we need to give it to 'runReader', which (in the case of Application) also needs to be given a '(Logger, Path)' pair.Using these functions directly can be a bit tedious. Some FP languages have special syntax which makes it nicer, e.g. in Haskell we can write things like:
The Scala equivalent is: These get rewritten to (the equivalent of): This API is a combination of Functor (map), Applicative (wrap and product) and Monad (join). Hence people sometimes call this "the Reader monad"; but don't let that scare you!