6
\$\begingroup\$

Background

I'm trying to write a helper function for constructing repeating strings with a separator.

// "hello world hello world hello"
"hello".repeat_and_join(3, " world ");
// "?,?,?"
'?'.repeat_and_join(3, ',');

The intended use case is to construct placeholders in sqlx query strings.

let ids = vec![52, 36, 29];
query_as::<_, MyEntity>(
 format!(
 r#"SELECT * FROM my_entities WHERE id IN ({})"#,
 '?'.repeat_and_join(ids.len(), ',')
 )
 .as_str()
)

Isn't this already supported by sqlx? Unfortunately, not at the time of writing.


Code

I wanted to create a generic method similar to join, supporting String, &str and char. Unfortunately, rust does not support method overloading - and I don't fully understand the syntax used to implement join. My attempt therefore makes use of a public method with an enum parameter and private implementation functions. However, I think this looks a bit ugly on the caller side (having to create the sep argument using e.g. StringOrChar::Char(',')) and causes a lot of repetition on the implementation side.

Is there a way to (i) make the caller API not have to use StringOrChar::X() and (ii) reduce the repeated code?

pub(crate) enum StringOrChar<'a> {
 String(String),
 Str(&'a str),
 Char(char),
}
pub(crate) trait StringExt {
 fn repeat_and_join(&self, n: usize, sep: StringOrChar) -> String;
}
impl StringExt for String {
 fn repeat_and_join(&self, n: usize, sep: StringOrChar) -> String {
 match sep {
 StringOrChar::String(sep) => repeat_and_join_str_str(n, self.as_str(), sep.as_str()),
 StringOrChar::Str(sep) => repeat_and_join_str_str(n, self.as_str(), sep),
 StringOrChar::Char(sep) => repeat_and_join_str_char(n, self.as_str(), sep),
 }
 }
}
impl StringExt for &str {
 fn repeat_and_join(&self, n: usize, sep: StringOrChar) -> String {
 match sep {
 StringOrChar::String(sep) => repeat_and_join_str_str(n, self, sep.as_str()),
 StringOrChar::Str(sep) => repeat_and_join_str_str(n, self, sep),
 StringOrChar::Char(sep) => repeat_and_join_str_char(n, self, sep),
 }
 }
}
impl StringExt for char {
 fn repeat_and_join(&self, n: usize, sep: StringOrChar) -> String {
 match sep {
 StringOrChar::String(sep) => repeat_and_join_char_str(n, *self, sep.as_str()),
 StringOrChar::Str(sep) => repeat_and_join_char_str(n, *self, sep),
 StringOrChar::Char(sep) => repeat_and_join_char_char(n, *self, sep),
 }
 }
}
fn repeat_and_join_str_str(n: usize, rep: &str, sep: &str) -> String {
 let mut s = String::with_capacity(rep.len() * n + sep.len() * (n.max(1) - 1));
 for _ in 0..(n.max(1) - 1) {
 s.push_str(rep);
 s.push_str(sep);
 }
 if n > 0 {
 s.push_str(rep);
 }
 s
}
fn repeat_and_join_str_char(n: usize, rep: &str, sep: char) -> String {
 let mut s = String::with_capacity(rep.len() * n + (n.max(1) - 1));
 for _ in 0..(n.max(1) - 1) {
 s.push_str(rep);
 s.push(sep);
 }
 if n > 0 {
 s.push_str(rep);
 }
 s
}
fn repeat_and_join_char_str(n: usize, rep: char, sep: &str) -> String {
 let mut s = String::with_capacity(n + sep.len() * (n.max(1) - 1));
 for _ in 0..(n.max(1) - 1) {
 s.push(rep);
 s.push_str(sep);
 }
 if n > 0 {
 s.push(rep);
 }
 s
}
fn repeat_and_join_char_char(n: usize, rep: char, sep: char) -> String {
 let mut s = String::with_capacity(n + (n.max(1) - 1));
 for _ in 0..(n.max(1) - 1) {
 s.push(rep);
 s.push(sep);
 }
 if n > 0 {
 s.push(rep);
 }
 s
}
Toby Speight
87.7k14 gold badges104 silver badges325 bronze badges
asked Aug 12 at 14:27
\$\endgroup\$
0

3 Answers 3

7
\$\begingroup\$

Warning: I am very new to Rust. At the moment I am not yet familiar with typical Rust habits. I am still transferring a lot of concepts from other languages I know, such as C#, C++ and Python. If you down-vote, please leave a comment so that I can learn from it.

Single responsibility

To me, the name repeat_and_join() implies a violation of the single responsibility principle. This method does 2 things. I would split that up into 2 methods, one for repeating and one for joining. Create one trait for each of them.

trait RepeatExt {
 fn times(&self, n: usize) -> Vec<String>;
}
trait JoinExt {
 fn join_with<S: Into<String>>(self, separator: S) -> String;
}

Use std::iter

The iter module helps you getting rid of manually coded loops.

Bringing it together

You can now write one repetition extension for each type (String, &str and char):

use std::iter;
impl RepeatExt for &str {
 fn times(&self, n: usize) -> Vec<String> {
 iter::repeat((*self).to_string()).take(n).collect()
 }
}
impl RepeatExt for String {
 fn times(&self, n: usize) -> Vec<String> {
 iter::repeat(self.clone()).take(n).collect()
 }
}
impl RepeatExt for char {
 fn times(&self, n: usize) -> Vec<String> {
 iter::repeat((*self).to_string()).take(n).collect()
 }
}

and the Join for just String:

impl JoinExt for Vec<String> {
 fn join_with<S: Into<String>>(self, separator: S) -> String {
 self.join(&separator.into())
 }
}

Testing

All 9 combinations work for me:

fn main() {
 println!("{}", "?".times(3).join_with(','));
 println!("{}", "?".times(3).join_with(","));
 println!("{}", "?".times(3).join_with(",".to_string()));
 
 println!("{}", '?'.times(3).join_with(','));
 println!("{}", '?'.times(3).join_with(","));
 println!("{}", '?'.times(3).join_with(",".to_string()));
 
 println!("{}", "?".to_string().times(3).join_with(','));
 println!("{}", "?".to_string().times(3).join_with(","));
 println!("{}", "?".to_string().times(3).join_with(",".to_string()));
}

Try it on the Rust playground

Evaluation

Is there a way to (i) make the caller API not have to use StringOrChar::X()

I removed that completely.

and (ii) reduce the repeated code?

Well, the 3 repetition methods are still similar, but a lot of duplication was removed using the iteration module. All in all, that's just 33 lines of code, compared to your original 96 lines.

answered Aug 12 at 15:59
\$\endgroup\$
3
  • 4
    \$\begingroup\$ I believe you are right about the single responsibility principle - I just got upset that both repeat and join exist but not a way to bridge them. However, based on your answer I was able to approach the problem differently and find intersperse. This allows me to write repeat_n('?', ids.len()).intersperse(',').collect::<String>().as_str(), without custom implementations \$\endgroup\$ Commented Aug 12 at 18:08
  • \$\begingroup\$ @FelixZY: intersperse is actually a nice name. I didn't know that one yet. \$\endgroup\$ Commented Aug 12 at 18:24
  • 1
    \$\begingroup\$ @ThomasWeller: Rust developers tend to be obsessed with performance, so we do not like the idea of avoidable allocations... and therefore returning Vec<String> intermediaries when iterators (for Vec) or &str (for String) would suffice. \$\endgroup\$ Commented Aug 13 at 13:20
5
\$\begingroup\$

Disclaimer: this answer focuses on performance.

Avoid creating a String for format!

The format! macro does not preserve any of its arguments, and therefore any allocation made just to format are gratuitous and best avoided.

Therefore, rather than formatting a String and pass it to format, it would be more idiomatic to create an object which implements Display.

The simplest approach is to stick to a single object, and ignore violations the Single Responsibility Principle.

On the playground:

struct RepeatJoin<R, J> {
 count: usize,
 repeated: R,
 joiner: J,
}
impl<R, J> RepeatJoin<R, J> {
 fn new(count: usize, repeated: R, joiner: J) -> Self {
 Self { count, repeated, joiner }
 }
}
impl<R, J> Display for RepeatJoin<R, J>
where
 R: Display,
 J: Display
{
 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
 let Some(count) = self.count.checked_sub(1) else {
 return Ok(());
 };
 
 write!(f, "{}", self.repeated)?;
 for _ in 0..count {
 write!(f, "{}", self.joiner)?;
 write!(f, "{}", self.repeated)?;
 }
 Ok(())
 }
}

Which allows you to write your query as:

 format!(
 r#"SELECT * FROM my_entities WHERE id IN ({})"#,
 RepeatJoin::new(ids.len(), '?', ',')
 )

And for which a single String will be allocated, which the performance conscious may even pre-allocate by using write!(&mut my_string, ...) instead.

Single Responsibility Principle

While one can see the repetition and join as two distinct operations, in this specific case I would just outright ignore the criticism.

Joiners only make sense in the context of repetitions, anyway.

Still, the abstraction can be split if one wishes to.

struct JoinDisplay<I, J> {
 iterator: I,
 joiner: J,
}
impl<I, J> JoinDisplay<I, J> {
 fn new(iterator: I, joiner: J) -> Self {
 Self { iterator, joiner }
 }
}
impl<I, J> Display for JoinDisplay<I, J>
where
 I: Clone + Iterator<Item: Display>,
 J: Display
{
 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
 let iterator = self.iterator.clone();
 for (i, e) in iterator.enumerate() {
 if i > 0 {
 write!(f, "{}", self.joiner)?;
 }
 write!(f, "{e}")?;
 }
 Ok(())
 }
}
answered Aug 13 at 13:44
\$\endgroup\$
2
\$\begingroup\$

Your extension trait can be replaced by using the standard library and the well-known itertools crate:

use itertools::Itertools;
use std::iter::repeat;
fn main() {
 let hello_world = repeat("hello").take(3).join(" world ");
 println!("{hello_world}");
 let question_marks = repeat('?').take(3).join(",");
 println!("{question_marks}");
}

https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=70c9b59cead5b7c6ba21ced88d2e89e6

No custom extension trait is needed.

The only downside is, that Itertools::join requires the separator to be a &str, so you cannot pass in a char directly, but given your use case, YAGNI.

answered Aug 19 at 10:58
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.