True Modularity - acowley/roshask GitHub Wiki

Why roshask?

Depending on who you are, the question of why you might want to use roshask can be answered in several ways. Some people may just want to use Haskell to interoperate with services mediated by ROS because they like Haskell, or want to make use of some library already written in Haskell. But the primary benefit of using roshask is what it provides for software design.

First-class Topics

Most software frameworks providing architectural support similar to what ROS provides -- discrete processes or nodes loosely coupled by message-based communication channels -- make the production of messages very opaque. One might write something like this in Python (from a ROS tutorial):

  while not rospy.is_shutdown():
    str = "hello world %s"%rospy.get_time()
    rospy.loginfo(str)
    pub.publish(String(str))
    rospy.sleep(1.0)

The problem with this block of code is that it is hard to give it a descriptive type. The issue is that the publishing action effectively has a void return type, which makes it extremely difficult to pass around what might otherwise be seen as generators.

Compare that with the corresponding roshask definition (from Examples/PubSub/src/Talker.hs),

    sayHello :: Topic IO S.String
    sayHello = repeatM (fmap mkMsg getCurrentTime)
      where mkMsg = S.String . ("Hello world " ++) . show

Here, sayHello is a first-class value that we can pass to other functions. Not only that, but its type is now descriptive enough that the type checker can ensure that our uses of sayHello are type safe.

The main benefit of being able to pass Topics around as first-class values is that we can now work with operations defined on those values. If we have two Topics whose values should be paired together, we can simply write everyNew topic1 topic2 to get a new pair of most recent values every time either Topic produces a new value. If we instead had to register a callback function when subscribing to a Topic, we would have to manage the requisite concurrency ourselves.

First-class Plumbing

The way a Node interfaces with the rest of the world may be thought of as its plumbing: there are input pipes, there are output pipes, and they each flow in or out of particular sections of code. This is primarily a declarative aspect of ROS component design that is defined almost entirely in terms of advertise and subscribe parts. By separating this plumbing declaration from any notion of an executable program, these interconnection specifications may be combined with other specifications to build up a larger specification.

Consider the roshask example package Examples/NodeCompose. This package nominally defines two nodes: the telescope Node that produces images, and the detectUFO Node that consumes images. These nodes can each be compiled into their own executables as in NodeCompose/src/JustScope.hs,

    module Main (main) where
    import Ros.Node
    import Telescope

    main = runNode "Scope" telescope

and, from NodeCompose/src/JustDetect.hs,


    module Main (main) where
    import Ros.Node
    import DetectUFO
    
    main = runNode "Detect" detectUFO

Running the two Node values, telescope and detectUFO, as separate executables means that data is passed from telescope to detectUFO via the "video" Topic in the standard ROS way. But sometimes the expense of this modularity due to the copying of large values like Images tempts us to break down the barriers and stuff telescope and detectUFO into the same Node. This is a horrible temptation to give in to! By keeping the two concerns separate, we are buying ourselves the flexibility to swap out the telescope back-end without touching detectUFO, or move one Node onto a different machine if that would offer some benefit.

So must abstraction cost us at runtime? No! Since we have isolated the declarative aspects of our Node definitions as (from Telescope.hs),

    telescope :: Node ()
    telescope = advertise "video" $ (topicRate 60 (runTopicState' images 0))

and (from DetectUFO.hs)

    detectUFO :: Node ()
    detectUFO = subscribe "video" >>= runHandler findPt >> return ()

we can simply compose the two Nodes when defining the executable process we want to run (from Main.hs),

    module Main (main) where
    import Ros.Node
    import Telescope
    import DetectUFO
    
    main = runNode "NodeCompose" $ telescope >> detectUFO

This composition has the benefit that message passing between telescope and detectUFO is simply pointer copying, rather than data copying. In this way, we have preserved the modularity of separate Nodes with concise, focused definitions that can be run as separate processes on different machines, but we have also left the door open for super-efficient single-process compositions of Nodes.

The key point here is that the same abstractions of Nodes and Topics are used in all cases!