Nuanced String Joining in Java

Donald Raab
Javarevisited
Published in
8 min readApr 14, 2024

--

Subtle differences sometimes make a big difference

Photo by Anja Bauermann on Unsplash

Starting with an Integer array

I opened up my IntelliJ IDE and created an Integer array. I don’t usually like boxing primitives but wanted to see what interesting things I could discover about Eclipse Collections and Java Stream without immediately jumping into primitive types.

Integer[] numbers = {1, 2, 3, 4, 5};

The first question I asked is “What can we do with this that is simple but interesting?“.” We can easily adapt this array in Java as a Stream.

Stream<Integer> stream = Stream.of(numbers);

We can also adapt it as an ArrayAdapter in Eclipse Collections.

ArrayAdapter<Integer> adapted = ArrayAdapter.adapt(numbers);

But what next? This is where our fun begins.

Let’s make a String

I wrote an article for 97 Things Every Java Programmer Should Know with the title “Learn to Kata and Kata to Learn.” In this article, I iterate through some example Java code using Collectors.joining() and String.join() to create a comma separated list of names. Then I turn the exercise into a code kata. I started with a List<String> and asserted some derived result of a joined String would equal the names in the List separated by commas.

This is a trivial example for both Collectors.joining and String.join. The example only involved a delimiter. It did not involve a prefix and a suffix. It also started off with String instances, which as we will see, is the perfect scenario for both Collectors.joining and String.join. Unfortunately, in the many classed world of object oriented programming, not everything starts out as a String.

Our problem today will start with the array of Integers values from 1 to 5 and converted them to a String starting with a “*”, separating elements with “*, *” and then finishing with a “*”.

For Loop

Joining an Integer array into a String with a for loop is a good exercise for us to start with.

@Test
public void loopArrayOfIntegerIntoAString()
{
Integer[] numbers = {1, 2, 3, 4, 5};

StringBuilder builder = new StringBuilder("*");
for (int i = 0; i < numbers.length; i++)
{
if (i > 0)
{
builder.append("*, *");
}
builder.append(numbers[i]);
}
String string = builder.append("*").toString();

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", string);
}

We use StringBuilder here, but I started off with simple String concatenation. IntelliJ recommending applying an automated refactoring to convert the String concatentation to StringBuilder. So I did. The code is fairly simple here to understand. We iterate over the array using indexed access, so that we can check if we are not on the first (0th) element. Only the first element element will not have the “*, *” delimiter before it. A prefix of “*” and a suffix of “*” are added to the StringBuilder before and after iteration begins. The append method in StringBuilder is nice enough to call String.valueOf on each Integer value for us so we don’t have to. In the end, we assert we wind up with a String that matches this string “*1*, *2*, *3*, *4*, *5*”.

Stream.map, collect, and Collectors.joining

For the next implementation, we will use a Stream and Collectors.joining to create the String. Collectors.joining requires that elements are an instance of CharSequence (a parent interface of String). We will map each Integer to a String using String.valueOf before calling Collectors.joining.

@Test
public void streamAnArrayOfIntegerAndMapCollectorsJoining()
{
Integer[] numbers = {1, 2, 3, 4, 5};

// Collectors.joining requires elements to be CharSequence
// delimiter is first, followed by prefix and then suffix
Stream<Integer> stream = Stream.of(numbers);
String streamedJoining = stream
.map(String::valueOf)
.collect(Collectors.joining("*, *", "*", "*"));

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", streamedJoining);
}

This code looks more concise than the for loop. However, it is rather annoying that StringBuilder append calls String.valueOf for us, and Collectors.joining does not. I also found it surprising that we see that joining takes the delimiter first, followed by the prefix and then the suffix. This does not mirror the output of the String.

String.join

For this implementation, we will used String.join. The String.join static method takes a CharSequence delimiter and either an array of CharSequence for the elements, or some kind of Iterable<CharSequence>. We can use the ArrayAdapter class from Eclipse Collections to convert the Integer array to an Iterable<CharSequence>.

@Test
public void adaptAnArrayOfIntegerAndStringJoin()
{
Integer[] numbers = {1, 2, 3, 4, 5};

Iterable<CharSequence> iterable = ArrayAdapter.adapt(numbers)
.asLazy()
.collect(String::valueOf);

// String.join only supports delimiter. Prefix and suffix are concatenated
// Requires elements to be a CharSequence[] or Iterable<CharSequence>
String stringJoin = "*" + String.join("*, *", iterable) + "*";

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", stringJoin);
}

Here we use the ArrayAdapter, convert it to a LazyIterable (which is an Iterable) and collect String instances using String::valueOf. The most surprising thing I didn’t know about String.join is that there are no public overloads that take a prefix and suffix, like Collectors.joining has.

We can also use Stream to turn the Integer array into an Iterable<String>.

@Test
public void streamAnArrayOfIntegerAndStringJoin()
{
Integer[] numbers = {1, 2, 3, 4, 5};

Iterable<String> iterable =
Stream.of(numbers).map(String::valueOf)::iterator;

String stringJoin = "*" + String.join("*, *", iterable) + "*";

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", stringJoin);
}

We use a method reference here of ::iterator on the Stream to convert it to an Iterable. This is a neat trick since Iterable only requires one method to be implemented, which is iterator.

ArrayAdapter.makeString

In the 97 Things article, I did not include a refactoring to use Eclipse Collections makeString. We will see how to use makeString, which is available on all RichIterable subtypes, below.

@Test
public void adaptAnArrayOfIntegerAndMakeString()
{
Integer[] numbers = {1, 2, 3, 4, 5};

ArrayAdapter<Integer> adapted = ArrayAdapter.adapt(numbers);

// makeString does not require elements to be CharSequence
// prefix is first, then delimiter and then suffix
String makeString = adapted.makeString("*", "*, *","*");

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", makeString);
}

Similar to StringBuilder append, makeString applies the call to String.valueOf for us. The order of String parameters here is prefix, delimiter, suffix, which more closely mirrors the expected output.

ArrayIterate.makeString

The simplest solution we will see is using ArrayIterate.makeString from Eclipse Collections. Eclipse Collections has utility classes with the suffix of Iterate. There are Iterate, MapIterate, StringIterate, and ArrayIterate utility classes, that work with Iterable, Map, String, and Object array types respectively.

@Test
public void iterateAnArrayOfIntegerAndMakeString()
{
Integer[] numbers = {1, 2, 3, 4, 5};

String makeString =
ArrayIterate.makeString(numbers, "*", "*, *","*");

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", makeString);
}

The makeString method on the utility classes behaves the same as on RichIterable subtypes. The method does not require the elements in the array to be some type of String or CharSequence already. The prefix comes first after the array parameter, followed by delimiter and then by suffix.

Looking for more nuance? Let’s get primitive.

Instead of an array of Integer, let’s switch to an array of int, and see what options we have to convert to a delimited String.

MutableIntList.makeString

We can convert an int array to a MutableIntList and use makeString on a primitive List.

@Test
public void mutableIntListMakeString()
{
int[] numbers = {1, 2, 3, 4, 5};

MutableIntList adapted = IntLists.mutable.with(numbers);

String makeString = adapted.makeString("*", "*, *", "*");

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", makeString);
}

IntStream.of, boxed and mapToObject

We can create an IntStream using the int array with the static of method, and then box it to Integer using the method boxed. There are no primitive Collectors for primitive Streams, so boxing is our only option.

@Test
public void intStreamBoxedCollectorsJoining()
{
int[] numbers = {1, 2, 3, 4, 5};

Stream<Integer> boxed = IntStream.of(numbers).boxed();
String streamedJoining = boxed
.map(String::valueOf)
.collect(Collectors.joining("*, *", "*", "*"));

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", streamedJoining);
}

We can also get rid of the boxing to Integer, and box straight from int to String using mapToObj with String.valueOf.

@Test
public void intStreamMapToStringCollectorsJoining()
{
int[] numbers = {1, 2, 3, 4, 5};

String string = IntStream.of(numbers)
.mapToObj(String::valueOf)
.collect(Collectors.joining("*, *", "*", "*"));

Assertions.assertEquals("*1*, *2*, *3*, *4*, *5*", string);
}

Update: Some Benchmarks

A reader commented that they would be interested in seeing some benchmarks. I was not really interested in spending time writing or running any benchmarks for these method comparisons, but I did find a set of benchmarks that already existed in the Eclipse Collections repo comparing Collectors.joining() on a Stream with makeString() in Eclipse Collections object and primitive types. So I decided to run the benchmarks while drinking some coffee today.

I set the SIZE of the collections to 100. I changed the TimeUnit to MILLISECONDS, so the measurements are in Operations per Millisecond.

I limited the tests to the following methods.

@Benchmark
public String serial_lazy_mapToStringJoining_jdk()
{
// Stream with an ArrayList<Integer> of size 100
return this.integersJDK.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
}

@Benchmark
public String serial_lazy_mapToStringJoining_ec()
{
// Stream with a FastList<Integer> of size 100
return this.integersEC.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
}

@Benchmark
public String serial_eager_makeString_ec()
{
// makeString with a FastList<Integer> of size 100
return this.integersEC.makeString(",");
}

@Benchmark
public String serial_eager_primitiveMakeString_ec()
{
// makeString with IntArrayList of size 100
return this.intList.makeString(",");
}

The results were obtained on my MacBook Pro M2 Max on Sonoma 14.4.1 using Azul Zulu JDK 21 and were as follows.

Benchmark                             Mode  Cnt    Score    Error   Units
serial_eager_makeString_ec thrpt 20 739.087 ± 17.148 ops/ms
serial_eager_primitiveMakeString_ec thrpt 20 828.930 ± 5.360 ops/ms
serial_lazy_mapToStringJoining_ec thrpt 20 433.049 ± 5.491 ops/ms
serial_lazy_mapToStringJoining_jdk thrpt 20 424.496 ± 7.619 ops/ms

The bigger the numbers, the better the performance. I do not personally find these kind of microbenchmark comparisons interesting. All the code examples here are extremely fast, and would only be a bottleneck in a system generating a million calls to these methods per second, or possibly from generating Strings from very large collections. More likely, there will be some other bottleneck in the code.

I prefer makeString because it requires less code and arguably makes the code easier to read. I hope folks would prefer makeString because it takes less code and is more readable, not because makeString is faster in this benchmark run than Collectors.joining(). If anyone finds these isolated microbenchmarks useful and wants to take the time to investigate to understand why the numbers are different, you have the source. Enjoy!

Summary

In this blog we saw how to use several approaches to convert the elements of an Integer array to a delimited String with a prefix and a suffix. The following are the methods we explored.

for loop
Stream.map, collect, and Collectors.joining
String.join
ArrayAdapter.makeString
ArrayIterate.makeString

We then explored converting the Integer array to an int array, and looked at how we can convert that to a delimited String.

MutableIntList.makeString
IntStream.of, boxed and mapToObj

There are nuances to each of these methods. The ArrayIterate approach wound up being the most concise for this particular use case.

Thank you for reading this blog and I hope you find the solutions I shared to this problem informative.

Enjoy!

I am the creator of and committer for the Eclipse Collections OSS project, which is managed at the Eclipse Foundation. Eclipse Collections is open for contributions.

--

--

Donald Raab
Javarevisited

Java Champion. Creator of the Eclipse Collections OSS Java library (https://github.com/eclipse/eclipse-collections). Inspired by Smalltalk. Opinions are my own.