Nuanced String Joining in Java
Subtle differences sometimes make a big difference
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.