sasa1977 / exactor

Helpers for simpler implementation of GenServer based processes
MIT License
683 stars 23 forks source link

Why was ExActor not a good idea? #35

Closed TD5 closed 3 years ago

TD5 commented 3 years ago

From the README.md:

I don't maintain this project anymore. In hindsight, I don't think it was a good idea in the first place. I haven't been using ExActor myself for years, and I recommend sticking with regular GenServer instead :-)

Can you expand on why ExActor wasn't a good idea? On the surface of it, removing boilerplate and standardising the APIs to gen_servers sounds good.

sasa1977 commented 3 years ago

The problem with ExActor is that it combines two functions running in different contexts (client and the server) into a single construct. This approach works for very simple operations, but it has limitations when you want to do something only on the client or only on the server. Consider the following example:

defcall div(x, y), state: state do
  # ...
end

Suppose you want to raise an exception in the client if y == 0. ExActor will basically not work here, and you have to fall back to the regular GenServer. I've tried to mitigate this to some extent (see this part of docs), but even that won't cover all the cases, while it introduces additional macro magic and implicit rules.

Let's take a look at a std. GenServer:

def div(x, y) do
 # client-side specific
 if y == 0, do: raise "division by zero"

 # sending a request to the server
 GenServer.call(pid, {:div, x, y})
end

# request handling on the server
def handle_call({:div, x, y}, _from, state), do: ...

Here we have full flexibility, and we can therefore easily add client-side or server-side specifics, such as client-side validation, process discovery, load-balancing, deferred request handling, etc.

The cost is that we add a bit of duplication, but I think that this duplication is a natural consequence of the fact that we're implementing both sides: the client and the server. If you e.g. implement a REST server and a corresponding client you'll end up with a similar amount of duplication. Such duplication can be eliminated, but in the process you'll lose options for handling specifics on just one side of the communication.

Consequently, I now think that ExActor is a good example of "wrong abstraction is worse than duplication". ExActor shaves off a bit of LOC and boilerplate, but it does this at the cost of non-standard macro magic, implicit fine-print details, and reduced flexibility. Since it doesn't cover all the options, you'll still need to learn GenServer and use it occasionally. Therefore I think that people are better off using GenServer all the time. It is more flexible, less magical, and IMO easier to understand than ExActor.

TD5 commented 3 years ago

Thank you, @sasa1977 - that makes sense

michalmuskala commented 3 years ago

@sasa1977 I wonder, if perhaps, this could be mitigated by suggesting creating wrapper functions for cases like this. In particular, for the example at hand:

def div(x, y) do
 # client-side specific
 if y == 0, do: raise "division by zero"

 # sending a request to the server
 do_div(x, y)
end

defcallp do_div(x, y), state: state do
  # ...
end
sasa1977 commented 3 years ago

Good point! This is basically why defcallp/defcastp exist. However, I'm not sure what's the benefit of this over plain GenServer, since now we end up with almost the same amount of duplication/boilerplate :-)

sasa1977 commented 3 years ago

For the sake of completeness, I should note that I have a somewhat different metaprogramming take on GenServer, which is vaguely described here.

This approach would actually lead to more code, not less :sweat_smile:, but I think it's worth trying out, because it could turn the messaging protocol into a first-class citizen, and improve typing. I don't plan to play with this myself, so in case someone wants to give it a try, the idea is up for grabs :-)