I'm afraid, this will become highly opinionated. Anyway, I try something.
... which happened before I realized, what Vavr actually provides. It attempts to cover everything mentioned here, like immutable data structures and monad syntax sugaring (with the For statement), and goes beyond that by coming up even with pattern matching. It takes a comprehensive set of FP concepts and rebuilds them using Java and it is no surprise Scala comes into one's mind seeing this ("Vavr is greatly inspired by Scala").
Now the foundations of functional programming can't be covered by a single SO post. And it might be problematic to get familiar with them in a language like Java which isn't geared towards it. So perhaps it is better to approach them in their natural habitat like the Scala language, which is still in some proximity to Java, or Haskell, which is not.
Coming back from this detour applying the features of Vavr may be more straight foward for the initiated. But likelely not for the Java developer sitting next to you in the office, who is less willing to go the extra mile and comes up with arguments that can't be just dismissed, like this one: "If we wanted to it that way, we would be a Scala shop". Therefore I'd say, applying Vavr asks for a pragmatic attitute.
To corroborate the Vavra-Scala argument, let's take Vavra's For
construct (all List
s mentioned are io.vavr.collection.List
), it looks like this:
Iterator<Tuple2<Integer, String>> tuples =
For(List.of(1, 2, 3), i ->
For(List.of(4, 5, 6))
.yield(a -> Tuple.of(i, String.valueOf(a))));
In Scala you'd encounter For
and yield
this way.
val tuples = for {
i <- 1 to 3
a <- 4 to 6
} yield (i, String.valueOf(a))
All the monad machinery remains under the hood, where Vavra brings more of an approximation, necessarily leaking some internals. For the purpose of learning it might be puzzling to start with Vavra's hybrid creatures.
So what remains of my post is a small time treatment of some FP basics, using the example of the OP, elaborating on immutability and Try
on a trench-level, but omitting pattern matching. Here we go:
One of the defining characteristics of FP are functions free of side effects ("pure functions"), which naturally (so to speak) comes along with immutable data structures/objects, which may sound kind of weird. One obvious pay off is, that you don't have to worry, that your operations create unintended changes at some other place. But Java doesn't enforce that in any way, also its immutable collections are only so on a superficial level. From the FP signature characteristics Java only offers higher order functions with java-lambdas.
I used the functional style quite a bit on the job manipulating complicated structures where I stuck to those 2 principles. E.g. load a tree T of objects from a db, do some transformations on it, which meant producing another tree of objects T', sort of one big map
operation, place the changes in front of the user to accept or reject them. If accepted, apply the changes to the related JPA entities and persist them. So after the functional transformation two mutations were applied.
I'd propose, to apply FP in this sense and tried to formulate an according version of your code, using an immutable DocVersion
class. I chose to simplify the Metadata part for the sake of the example.
I also tried to highlight, how the "exception-free" Try
approach (some of it poached from here) could be formulated and utilized some more. Its a small time version of Vavr's Try, hopefully focusing on the essentials. Note its proximity to Java's Optional and the map
and flatMap
methods in there, which render it an incarnation of the FP concept called monad. It became notorious in a sweep of highly confusing blog posts some years ago usually starting with "What is a monad?" (e.g. this one). They have cost me some weeks of my life, while it is rather easy to get a good intuition of the issue just by using Java streams or Optionals. Miran Lipovaca's "Learn Yourself a Haskell For Great Good" later made good for it to some extent, and Martin Odersky's Scala language.
Boasting with of
, map
and flatMap
, Try
would, roughly speaking, qualify for a syntax-sugaring like you find it in C# (linq-expressions) or Scala for-expressions. In Java there is no equivalent, but some attempts to at least compensate a bit are listed here, and Vavr looks like another one. Personally I use the jool library occasionally.
Passing around streams as function results seems not quite canonical to me, since streams are not supposed to get reused. That's also the reason to create a List as an intermediary result in process()
.
public class PathHandler {
class DocVersionDAO {
public void save(DocVersion v) {
}
public DocVersion validate(DocVersion v) {
return v;
}
public Stream<DocVersion> getAllForPathProcessing() {
return null;
}
}
class Metadata {
@Id
private final Long id;
private final String value;
Metadata() {
this.id = null;
this.value = null;
}
Metadata(Long id, String value) {
this.id = id;
this.value = value;
}
public Optional<String> getValue() {
return Optional.of(value);
}
public Metadata withValue(String value) {
return new Metadata(id, value);
}
}
public @interface Id {
}
class DocVersion {
@Id
private Long id;
private final Metadata metadatata;
public Metadata getMetadatata() {
return metadatata;
}
public DocVersion(Long id) {
this.id = id;
this.metadatata = new Metadata();
}
public DocVersion(Long id, Metadata metadatata) {
this.id = id;
this.metadatata = metadatata;
}
public DocVersion withMetadatata(Metadata metadatata) {
return new DocVersion(id, metadatata);
}
public DocVersion withMetadatata(String metadatata) {
return new DocVersion(id, this.metadatata.withValue(metadatata));
}
}
private DocVersionDAO dao;
public List<DocVersion> process() {
List<Tuple2<DocVersion, Try<DocVersion>>> maybePersisted = dao.getAllForPathProcessing()
.map(d -> augmentMetadata(d, LocalDateTime.now().toString()))
.map(d -> Tuple.of(d, Try.of(() -> dao.validate(d))
.flatMap(this::trySave)))
.peek(i -> i._2.onException(this::logExceptionWithBadPracticeOfUsingPeek))
.collect(Collectors.toList());
maybePersisted.stream()
.filter(i -> i._2.getException().isPresent())
.map(e -> String.format("Item %s caused exception %s", e._1.toString(), fmtException(e._2.getException().get())))
.forEach(this::log);
return maybePersisted.stream()
.filter(i -> !i._2.getException().isPresent())
.map(i -> i._2.get())
.collect(Collectors.toList());
}
private void logExceptionWithBadPracticeOfUsingPeek(Exception exception) {
logException(exception);
}
private String fmtException(Exception e) {
return null;
}
private void logException(Exception e) {
log(fmtException(e));
}
public DocVersion augmentMetadata(DocVersion v, String augment) {
v.getMetadatata().getValue()
.ifPresent(m -> log(String.format("Doc %d has matadata %s, augmenting it with %s", v.id, m, augment)));
return v.withMetadatata(v.metadatata.withValue(v.getMetadatata().value + augment));
}
public Try<DocVersion> trySave(DocVersion v) {
return new Try<>(() -> {
dao.save(v);
return v;
});
}
private void log(String what) {
}
}
Try looks like this
public class Try<T> {
private T result;
private Exception exception;
private Try(T result, Exception exception) {
this.result = result;
this.exception = exception;
}
public static <T> Try<T> of(Supplier<T> f)
{
return new Try<>(f);
}
T get() {
if (result == null) {
throw new IllegalStateException();
}
return result;
}
public void onException(Consumer<Exception> handler)
{
if (exception != null)
{
handler.accept(exception);
}
}
public <U> Try<U> map(Function<T, U> mapper) {
return exception != null ? new Try<>(null, exception) : new Try<>(() -> mapper.apply(result));
}
public <U> Try<U> flatMap(Function<T, Try<U>> mapper) {
return exception != null ? null : mapper.apply(result);
}
public void onError(Consumer<Exception> exceptionHandler) {
if (exception != null) {
exceptionHandler.accept(exception);
}
}
public Optional<Exception> getException() {
return Optional.of(exception);
}
public Try(Supplier<T> r) {
try {
result = r.get();
} catch (Exception e) {
exception = e;
}
}
}