I'm trying to build a library where you can add and remove listeners for events in a pub/sub system, but am running into an issue using method references:
// here, this::printMessage is being passed as an instance of Consumer<String>
pubSub.subscribe(this::printMessage);
pubSub.unsubscribe(this::printMessage);
Internally, calling subscribe() will add the instance of Consumer<T> to a Set<Consumer<T>>, and unsubscribe() will remove it. This issue arises from the fact that each usage of this::printMessage here actually causes the compiler to generate a new object reference/instance, so, unsubscribing doesn't actually work.
The workaround so far that I've managed is:
final Consumer<String> consumer = this::printMessage;
pubSub.subscribe(consumer);
pubSub.unsubscribe(consumer);
But, this isn't really ideal. My concern is someone less-experienced using this library may assume that they can use method references directly when subscribing/unsubscribing, when that's not really the case, and worst case, leading to a memory leak.
So the question is, is there some clever way to avoid this or coerce the method reference to always resolve to the same object reference/instance?
2 Answers 2
You could make subscribe either return the actual Consumer instance or an identifier for the added Consumer. This return value could be used in unsubscribe to remove the Consumer again.
Maybe something similar to this:
Map<UUID, Consumer<?>> consumers = new ConcurrentHashMap<>();
public UUID subscribe(Consumer<?> consumer) {
UUID identifier = UUID.randomUUID();
consumers.put(identifier, consumer);
return identifier;
}
public void unsubscribe(UUID identifier) {
consumers.remove(identifier);
}
The usage of an identifier instead of the actual Consumer instance as return value has the advantage that users of your code will directly see that they need to keep track of the returned UUID instead of using unsubscribe with a different 'identical' (in terms of behavior) Consumer.
5 Comments
UUID for that, as a plain object without any properties would do.equals and hashCode implementation. The default implementation makes this object equals nothing but itself, which is precisely the desired behavior. In contrast, a UUID allows the construction of a different equal object via the string representation, which is not what you want here. You want the subscriber to keep the returned object and pass it back to unsubscribe, not to do transformations nor conversions on it.UUID to a String doesn’t change that. You would have to keep the string then. And the question was about a limitation of method references over ordinary objects. Ordinary listeners do not have a capability of reconstructing them via a string representation either. And there’s no sense in providing a persistent key form for a registration that doesn’t last longer than this runtime anyway.When you write code like :
pubSub.subscribe(this::printMessage);
pubSub.unsubscribe(this::printMessage);
It is similar code like:
pubSub.subscribe(new Consumer() {
@Override
public void accept(Object t) {
// your code here
};
});
pubSub.unsubscribe(new Consumer() {
@Override
public void accept(Object t) {
// your code here
};
});
So from the above code, it is clear it will create a new object every time.
Now why those are similar code?
Java has introduced byte code instruction invokedynamic to construct the anonymous class and then generate byte code for lambdas/ method ref. So, In case of lambdas/method ref java generates the implementation class and generate byte code at runtime. Once the byte code generates the rest of the step is the same as a normal method call.
Is there some clever way to avoid this or coerce the method reference to always resolve to the same object reference/instance?
-> I don't believe there is any other clever way (other than what you have done as work around) to do that.
this::printMessageshould know that the compiler is generating a new object instance. It is not a problem of your library actually.Subscriptionobject with anunsubscribe()method instead to avoid mistakes like this in the API.