Question on a simple REPL example for side effects

Hi all,

Today I wanted to showcase a simple example of side effects as handled in Haskell and in Python on social media. I thought I understood it, but maybe not because a comment made me doubt enough to take down the post. I’d love to get more insight here.

Consider the following snippet from a ghci session:

>>> e = print 5
>>> :type e
e :: IO ()
>>> e
5
>>>

And here is one from Python:

>>> e = print(5)
5
>>> type(e)
<class 'NoneType'>
>>> e
>>>

To me, the 5 appearing under the variable assignment in Python is clearly a side effect due to evaluating print 5 and assigning what it returns (None) to e, while on the Haskell end print 5 gets bound as IO () to e without getting evaluated until passed top-level to the REPL which can run IO. However, the commenter mentioned this was just due to lazyness on the Haskell side, saying that the print function only gets evaluated anyway once you display e in the REPL. Fair, and he also provided examples of languages F# and OCAML that can behave differently, showing 5 in the REPL immediately after the assignment.

A bit confused, I tested what Purescript (strict and pure language afaiu) does and got the same behavior as in Haskell …

>>> import Effect.Console (logShow)
>>> :t logShow
forall a. Show a => a -> Effect Unit

>>> e = logShow 5
>>> :type e
Effect Unit
>>> e
5
unit
>>>

I thus still think it’s a valid example and it’s correct to say Haskell doesn’t show it immediately because it’s capturing side effects in a IO () datatype. But is this correct? My final thought was, that yes, lazyness and capturing-side effects in a type like IO are closely related in that they both delay execution until triggered by something. That something is, however, more constraint for IO, requiring to be a very specific context such as the REPL.

Do you think observing that side effect after a = print(5) in the REPL is a valid example? If not, what alternatives are equally short, common and easy to understand?

I don’t think it has anything to do with laziness:

ghci> !e = print 5 
ghci>
1 Like

I think your example is a valid one.

In Python 3, function print() has a side effect unconnected with the value it yields - it changes the state of the world.

In Haskell, print 5 has no side effect; it evaluates to an action that, when (later) applied to a initial state of the world by the run time system, will change the state of the world (printing 5).

(I found chapter 5 of Well-Typed’s introductory course very helpful: Part 5: IO and Explicit Effects - Well-Typed: The Haskell Consultants)

3 Likes

The Haskell wiki may also help to clarify matters:

1 Like

Under the very special narrow context of comparing REPLs, e = print(5) at the Python prompt should be translated to e <- print 5 at the Haskell prompt.

But 99.9% of the time you are not writing real code in the REPL, you write real code into a file before ordering the computer to run that file. Then here is a correct translation:

Haskell file:

e = print 5
main = do
  d <- print 4
  e

Python:

def e():
  print(5)
if __name__ == "__main__":
  d = print(4)
  e()
2 Likes

Agreed - in comparing REPLs, you risk comparing the features provided by (interactive) implementations of programming languages, rather than the features of the languages themselves.


Haskell’s laziness does both, with that “trigger” simply being the reduction of the given expression in the appropriate context:

  • For ordinary Haskell expressions free of externally-visible effects, the language itself provides that context.

  • But for I/O actions like print 5 :: IO (), the only safe context for their reduction is outside of Haskell - that context is provided directly by the Haskell implementation (since how primitive I/O actions really work is specific to each Haskell implementation).

Since you already understand the basics of I/O in Haskell, I also suggest reading:

OK, these are nice answers and links, thank you. Such a seemingly simple example, but still trickier than one would think. I do see the issue of comparing REPLs, in particular e = print 5 vs e <- print 5. On the other hand REPLs are also a great way for a comparison because of the rapid feedback, and because it’s simple to think one line at a time.

Maybe, staying within this example, a good approach is to simply show = and <-. And the fact that there isn’t really an equivalent in Python (unless using the def construct or libraries) illustrates the same point.

Perhaps worth observing that, in Python, if ... is a value then e = ... corresponds to Haskell’s let e = ..., but if ... is a function call then e = ... corresponds to Haskell’s e <- ....

2 Likes

Thinking about the “def” construct in @treblacy 's answer. I feel that translation is closer to lazyness because you can execute a function in any context. IO on the other hand is a pretty specific type that can only be evaluated by very specific evaluators as @atravers mentioned, such as when using a top level REPL prompt.

There is no Python 3 equivalent of Haskell’s print :: Show a => a -> IO ().

However, with GHC, and outside of the confines of the Haskell 2010 Report, you can get something equivalent-ish to Python’s print(). The following introduces side effects (in the standard error output stream):

ghci> import Debug.Trace ( trace ) -- from base package
ghci> :type trace
trace :: String -> a -> a
ghci> :type flip trace ()
flip trace () :: String -> ()
ghci> flip trace () "this is the side effect" -- evaluates to ()
this is the side effect
()

trace exists to help with debugging ‘Haskell proper’.

You can kind of hack it together:

from dataclasses import dataclass
from typing import Callable, TypeVar, Generic

A = TypeVar('A')

@dataclass
class Pure(Generic[A]):
    result: A

@dataclass
class Print(Generic[A]):
    msg: str
    k: 'IO[A]' # mutually recursive types are weird in Python

IO = Print[A] | Pure[A]

def run(action: IO[A]) -> A:
    match action:
        case Print(msg, k):
            print(msg)
            return run(k)
        case Pure(x):
            return x

Defining an IO action does not print anything:

hello_world: IO[None] = Print("Hello, World!", Pure(None))

It only prints when we run an IO action:

run(hello_world)

Haskell does not have an explicit run function; the only things that are run is the main IO action and any IO actions that you write directly in GHCi.

2 Likes

Hmm - if Python’s None type is isomorphic to (), Haskell’s unit type, another option is (a Python version of) Andrew Gordon’s Job:

(or see section 3.4 of How to Declare an Imperative.)