How to write idiomatic (non-OOP) Julia version of OOP design pattern?

I learned a lot about Object Oriented Programming from this blog article from 2020 about “Gang of Four” design patterns. I should say that I learned about OOP long ago via a Java book but as a data professional I have never needed to know much about these things. I should emphasize that OOP never clicked with me, and when I discovered Julia it felt like a natural language.

How would we properly write idiomatic (non-OOP) Julia version of the solution? The python for the blog author’s preferred solution is pasted far below.

Here is my attempt in Julia to respond to the author’s preferred solution to a toy implementation of Logger in python, using the “Composition over Inheritance” principle from “Design Patterns: Elements of Reusable Object-Oriented Software”. Their solution avoids the explosion of subclasses problem that can happen with inheritance in OOP.

function log_message(message::String, sink::T) where {T <: AbstractPath}
    io = open(sink, "a")
    write(io, message * "\n")
    close(io)
end

function log_message(message::String, sink::TCPSocket)
    send(socket::UDPSocket, message)
end

function log_message(message::String, sink::SysLog)
    write(sink, message)
end

function message_filter(pattern::Vector{String}, message::String)
    for x in pattern
        if  occursin(x, message)
            return message
        end
    end
end

# TEST: log_message function
using FilePathsBase
msg1 = "Error: Its a bad bad problem here"
msg2 = "Warn: eh ok fine"
file = Path("./test-logger.txt")

log_message(message_filter(["Error"], msg1), file)
# or pipe the filtered message
message_filter(["Warn"], msg2) |> (x -> log_message(x, file))

shell> cat test-logger.txt
    # Error: Its a bad bad problem here
    # Warn: eh ok fine

From “Solution #4: Beyond the Gang of Four patterns”, the python from the post is copied below.

This is the blog author’s favored solution in python using Composition instead of Inheritance.

class Logger:
    def __init__(self, filters, handlers):
        self.filters = filters
        self.handlers = handlers

    def log(self, message):
        if all(f.match(message) for f in self.filters):
            for h in self.handlers:
                h.emit(message)

# Filters now know only about strings!

class TextFilter:
    def __init__(self, pattern):
        self.pattern = pattern

    def match(self, text):
        return self.pattern in text

# Handlers look like “loggers” did in the previous solution.

class FileHandler:
    def __init__(self, file):
        self.file = file

    def emit(self, message):
        self.file.write(message + '\n')
        self.file.flush()

class SocketHandler:
    def __init__(self, sock):
        self.sock = sock

    def emit(self, message):
        self.sock.sendall((message + '\n').encode('ascii'))

class SyslogHandler:
    def __init__(self, priority):
        self.priority = priority

    def emit(self, message):
        syslog.syslog(self.priority, message)

# results
f = TextFilter('Error')
h = FileHandler(sys.stdout)
logger = Logger([f], [h])

logger.log('Ignored: this will not be logged')
logger.log('Error: this is important')
# Error: this is important

This might help.

if you search the forums for “oop”, you will find many threads that have discussed this topic.

Thanks for the reply! I did see the post you referenced, and others that are attempting to migrate OOP design patterns into julia.

My question is a little different as my goal is idiomatic Julia with no OOP patterns, about a specific well-known example from a famous OOP book. I’ll rephrase the title:

I rewrote a toy OOP design pattern solution (“Composition over Inheritance”) into Julia using multiple dispatch instead of object methods, does this seem correct?

The sink is likely to be common among many calls. I think carrying the sink around in the calling code is onerous. Users of the OOP class wouldn’t need to do that; the instance encapsulates the filter and handlers. You can encapsulate the sink with a closure.

1 Like

Thanks for helping, I’m sorry for misunderstanding what you wrote. In Julia isn’t it preferable to use method dispatch based on argument type? In the toy problem above we can call log_message with a text message and desired target, the function argument sink.

# filepath is a subtype of AbstractPath, indicating a file location
# socket is a TCPSocket type
# syslog is a system log location of type SysLog

log_message("Error: This error just happened.", filepath)
log_message("Error: This error just happened.", socket)
log_message("Error: This error just happened.", syslog)

It doesn’t seem necessary to have handler objects, because log_message is defined for the types of log locations that we intend to support.

Sorry again if I missed something, I don’t understand the part about “carrying the sink around in the calling code.” In my imagination, if I need to send to a socket then the client code defines the socket and log_message can just write to there when its called.

Also I don’t understand why the sink argument needs to be encapsulated with a closure.

The sink argument, and the filter, and whatever other logging configuration ought to be encapsulated, so that clients don’t need to pass them through their entire call stack to wherever they need to log something. If only the sink is being encapsulated, then passing the sink is not much different than passing the closure, But if there are multiple different arguments to configuring logging, then keeping one closure is simpler and more maintainable than all those options. Also, a logging closure provides a higher level abstraction than passing around what’s essentially the implementation details of logging. If you need to add another option, you just modify the closure creation rather all the code that needs to do logging and all the code that may call code that needs to do logging. That’s error prone and makes code harder to maintain because at every level you must be concerned with the logging configuration details. Alternatively, you pass the logging configuration in a struct, and it looks like passing the OOP class instance. Indeed, a closure in Julia is just syntactic sugar for a callable struct. You could store the configured log_message closure in a const global if you have to and not pass it through your functions at all. You still use multiple dispatch to handle different sink types for the function that creates the closure. This is analogous to overloaded constructors for a logger class in OOP.

create_logger(sink::TCPSocket) =  message -> send(sink, message)
create_logger(sink::SysLog) = message -> write(sink)
# etc, possibly more methods with filters and maybe other logging configuration.

# usage option a
high_level_function(some_args, create_logger(s))
function some_lower_level_function(args, logger)
  #...
   logger(msg)
end

# usage option b
const log_message = create_logger(s)
high_level_function(some_args)
function some_lower_level_function(args)
   log_message(msg)
end
1 Like

Just a remark, if you still want to use OOP under Julia, I just saw this package: GitHub - Suzhou-Tongyuan/ObjectOriented.jl: Conventional object-oriented programming in Julia without breaking Julia's core design ideas
Maybe it can be helpful.

Excellent example, this makes it much more clear. I think I have a better understanding about the purpose of encapsulation. This functionality is part of the python code, but so elemental it isn’t mentioned in the article.

Since it hasn’t been mentioned, I guess the basic goal of comparing OOP compositional design to julia type dispatch is successful.

Thanks! This is a helpful resource, even though I do not want to use OOP. But in contrast to non-OOP designs it is helping me get a deeper understanding.