Stream macro

I been trying to make using streams simpler with this little macro:

macro stream(&block)
  String.build do |io|
    io << {{block}}
  end
end

macro puts_stream(&block)
  print stream do
    {{block}} << '\n'
  end
end

However, when I try to call it:

puts_stream {
  "Hello, world!"
}

I get error:

expecting identifier 'end', not 'do'

So macros does not accept blocks? Or am I doing something wrong?

It’s probably a dumb question, I been not using Crystal for a long time.

Not entirely sure I follow what the use case here is, but does this even need to be a macro? Probably would be a bit easier to implement as a normal method.

Either way I think what’s going on here is it’s expanding to something like:

print stream do
  do
    "Hello, world!"
  end << '\n'
end

which is of course invalid syntax.

My idea was to allow users to append to stream with <<. I was hoping to use it in other macros like:

macro colorize(s, color)
  stream do
    "\e[0;" << {{color}}.to_s << ";49m" << {{s}} << "\e[0m"
  end
end

macro red(s)
  colorize({{s}}, 31)
end

macro green(s)
  colorize({{s}}, 32)
end

macro yellow(s)
  colorize({{s}}, 33)
end

macro blue(s)
  colorize({{s}}, 34)
end

macro magenta(s)
  colorize({{s}}, 35)
end

You already can do this via IO#<<. It returns self so can chain it on a single line.

Also maybe could just use Colorize - Crystal 1.16.1 instead of implementing your own API?

1 Like

Thanks, I get that now. I don’t get though why am I still getting unexpected token: "<<" here:

macro stream(*args)
  io = IO::Memory.new
  (io
  {% for arg in args %}
    << {{arg}}
  {% end %})
  io.to_s
end

It should expand to something like:

 > 1 |   io = IO::Memory.new
 > 2 |   (io
 > 3 |     << "test"
 > 4 |   )
 > 5 |   io.to_s

Okay, never mind. It works on a single line:

macro stream(*args)
  io = IO::Memory.new
  (io {% for arg in args %} << {{arg}} {% end %})
  io.to_s
end
def stream(*args : String) : String
  args.join
end

Should do the same thing as you have, but a bit simpler.

Yes, but it uses String for interpolation. I meant to use stream to make it more efficient

Mmk, I’ll be curious to see the final product then. Because IO::Memory is also just in memory so not really getting any benefit there. Indexable#join might even be more efficient since it can make some assumptions about the data. Which is called out in the API docs:

Optimized version of Enumerable#join that performs better when all of the elements in this indexable are strings: the total string bytesize to return can be computed before creating the final string, which performs better because there’s no need to do reallocations.

I just assumed streams will be faster. So, this is also pointless :melting_face:?

macro stream(*args)
  String.build do |io|
    io {% for arg in args %} << {{arg}} {% end %}
  end
end

If this benchmark is correct:

require "benchmark"

def def_stream(*args : String) : String
  args.join
end

macro macro_stream(*args)
  String.build do |io|
    io {% for arg in args %} << {{arg}} {% end %}
  end
end

Benchmark.ips do |x|
  x.report "def_stream" do
    def_stream(
      "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
      "Phasellus nec sem id quam placerat viverra vel non urna.",
      "In quis odio et erat feugiat fermentum vitae id nibh.",
      "Sed sed erat et sem aliquet placerat posuere egestas est.",
      "Aenean at lectus rutrum, condimentum sem quis, cursus arcu.",
      "Nullam ac tortor pretium, scelerisque ex vel, ullamcorper felis.",
      "Quisque accumsan urna sed feugiat dignissim.",
      "Sed mattis lacus vitae felis tempus facilisis non a ipsum.",
      "Pellentesque suscipit tortor non quam mollis feugiat.",
      "Ut cursus lacus ac tellus gravida, eget bibendum mi hendrerit.",
      "Quisque eu est et libero volutpat dapibus.",
      "Aliquam lobortis urna viverra justo congue molestie.",
      "Maecenas efficitur nibh quis diam elementum, nec varius justo pulvinar.",
      "Donec et est bibendum, vehicula augue ut, tempor mi.",
      "Phasellus eget risus tincidunt, egestas ex in, tristique neque.",
      "Donec consectetur tellus quis consequat ullamcorper.",
      "Nullam sollicitudin augue nec lacus porta consequat.",
      "Fusce aliquam libero in orci semper, nec viverra enim elementum.",
      "Praesent mollis turpis vitae ex suscipit fringilla.",
      "Sed luctus tortor et odio lacinia, et feugiat augue vestibulum.",
    )
  end

  x.report "def_stream" do
    macro_stream(
      "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
      "Phasellus nec sem id quam placerat viverra vel non urna.",
      "In quis odio et erat feugiat fermentum vitae id nibh.",
      "Sed sed erat et sem aliquet placerat posuere egestas est.",
      "Aenean at lectus rutrum, condimentum sem quis, cursus arcu.",
      "Nullam ac tortor pretium, scelerisque ex vel, ullamcorper felis.",
      "Quisque accumsan urna sed feugiat dignissim.",
      "Sed mattis lacus vitae felis tempus facilisis non a ipsum.",
      "Pellentesque suscipit tortor non quam mollis feugiat.",
      "Ut cursus lacus ac tellus gravida, eget bibendum mi hendrerit.",
      "Quisque eu est et libero volutpat dapibus.",
      "Aliquam lobortis urna viverra justo congue molestie.",
      "Maecenas efficitur nibh quis diam elementum, nec varius justo pulvinar.",
      "Donec et est bibendum, vehicula augue ut, tempor mi.",
      "Phasellus eget risus tincidunt, egestas ex in, tristique neque.",
      "Donec consectetur tellus quis consequat ullamcorper.",
      "Nullam sollicitudin augue nec lacus porta consequat.",
      "Fusce aliquam libero in orci semper, nec viverra enim elementum.",
      "Praesent mollis turpis vitae ex suscipit fringilla.",
      "Sed luctus tortor et odio lacinia, et feugiat augue vestibulum.",
    )
  end
end

Then yes, the macro version is 3x slower than the method version:

$ crystal run --release test.cr
  def_stream  11.70M ( 85.46ns) (± 2.61%)  1.31kB/op        fastest
macro_stream   3.56M (281.24ns) (± 1.44%)  5.78kB/op   3.29× slower
1 Like

Thanks for sanity check. Look at this though:

require "benchmark"

def def_stream(*args : String) : String
  args.join
end

macro macro_stream(*args)
  {% result = "" %}
  {% for arg in args %}
    {% result += arg %}
  {% end %}
  {{ result }}
end

Benchmark.ips do |x|
  x.report "def_stream" do
    def_stream(
      "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
      "Phasellus nec sem id quam placerat viverra vel non urna.",
      "In quis odio et erat feugiat fermentum vitae id nibh.",
      "Sed sed erat et sem aliquet placerat posuere egestas est.",
      "Aenean at lectus rutrum, condimentum sem quis, cursus arcu.",
      "Nullam ac tortor pretium, scelerisque ex vel, ullamcorper felis.",
      "Quisque accumsan urna sed feugiat dignissim.",
      "Sed mattis lacus vitae felis tempus facilisis non a ipsum.",
      "Pellentesque suscipit tortor non quam mollis feugiat.",
      "Ut cursus lacus ac tellus gravida, eget bibendum mi hendrerit.",
      "Quisque eu est et libero volutpat dapibus.",
      "Aliquam lobortis urna viverra justo congue molestie.",
      "Maecenas efficitur nibh quis diam elementum, nec varius justo pulvinar.",
      "Donec et est bibendum, vehicula augue ut, tempor mi.",
      "Phasellus eget risus tincidunt, egestas ex in, tristique neque.",
      "Donec consectetur tellus quis consequat ullamcorper.",
      "Nullam sollicitudin augue nec lacus porta consequat.",
      "Fusce aliquam libero in orci semper, nec viverra enim elementum.",
      "Praesent mollis turpis vitae ex suscipit fringilla.",
      "Sed luctus tortor et odio lacinia, et feugiat augue vestibulum.",
    )
  end

  x.report "macro_stream" do
    macro_stream(
      "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
      "Phasellus nec sem id quam placerat viverra vel non urna.",
      "In quis odio et erat feugiat fermentum vitae id nibh.",
      "Sed sed erat et sem aliquet placerat posuere egestas est.",
      "Aenean at lectus rutrum, condimentum sem quis, cursus arcu.",
      "Nullam ac tortor pretium, scelerisque ex vel, ullamcorper felis.",
      "Quisque accumsan urna sed feugiat dignissim.",
      "Sed mattis lacus vitae felis tempus facilisis non a ipsum.",
      "Pellentesque suscipit tortor non quam mollis feugiat.",
      "Ut cursus lacus ac tellus gravida, eget bibendum mi hendrerit.",
      "Quisque eu est et libero volutpat dapibus.",
      "Aliquam lobortis urna viverra justo congue molestie.",
      "Maecenas efficitur nibh quis diam elementum, nec varius justo pulvinar.",
      "Donec et est bibendum, vehicula augue ut, tempor mi.",
      "Phasellus eget risus tincidunt, egestas ex in, tristique neque.",
      "Donec consectetur tellus quis consequat ullamcorper.",
      "Nullam sollicitudin augue nec lacus porta consequat.",
      "Fusce aliquam libero in orci semper, nec viverra enim elementum.",
      "Praesent mollis turpis vitae ex suscipit fringilla.",
      "Sed luctus tortor et odio lacinia, et feugiat augue vestibulum.",
    )
  end
end 

output:

def_stream   2.98M (335.77ns) (± 6.97%)  1.31kB/op  252.88× slower
macro_stream 753.13M (  1.33ns) (± 7.07%)    0.0B/op         fastest

Yes this makes sense as with this version you’re essentially building the final string at compile time and including it in the program. So at runtime nothing has to be done as it would be the same as hard coding the string. {{args.join ""}} would have the same result, but a bit more readable.

However one thing to keep in mind is as it stands this macro version can only handle cases where all the strings are provided as hard coded strings. I.e. you couldn’t pass it local vars to the macro version. For one because it cannot handle Var input nodes, and if you fix that (e.g. using args.join ""), because it cannot know the runtime value of those variables.

def def_stream(*args : String) : String
  args.join
end

macro macro_stream(*args)
  {{ args.join "" }}
end

foo = "i am a string"

puts macro_stream foo, foo, foo # => foofoofoo
puts def_stream foo, foo, foo       # => i am a stringi am a stringi am a string
1 Like

Yes, this is what I needed. Don’t worry, I know it’s compile time and not really type safe.

Not sure if related to what you are trying to do, but check how Log.define_formatter works.

Is a macro that receives a string literal with interpolation. The interpolated values becomes into scope thanks to a the base class.

In the end you use regular string interpolation syntax but that string is never built.

1 Like