In Progress
Unit 1, Lesson 1
In Progress

Advanced String Formats

Video transcript & code

In the last episode we covered the basics of using formatting codes to lay out complex data. Today we're going to pick up where we left off and go over some more advanced string formatting features.

We'll start with the temperature listing code we used previously. Currently, it shows negative numbers with a minus sign, and positive numbers with no prefix.

require "./data"
DAYS.each_with_index do |day, i|
  puts "%-9s High: %3.1f Low: %3.1f" % [day, C(HIGHS[i]), C(LOWS[i])]
end
# >> Tuesday   High: 20.6 Low: 1.1
# >> Wednesday High: 17.2 Low: -2.2
# >> Thursday  High: -1.1 Low: -7.8
# >> Friday    High: 12.2 Low: -7.2
# >> Saturday  High: 16.1 Low: -1.1
# >> Sunday    High: 3.9 Low: -2.2
# >> Today     High: 0.0 Low: -6.1

If we wanted, we could show a sign for both positive and negative values by specifying the + modifier. We also need to update the field size to accommodate the extra character.

require "./data"
DAYS.each_with_index do |day, i|
  puts "%-9s High: %+5.1f Low: %+5.1f" % [day, C(HIGHS[i]), C(LOWS[i])]
end
# >> Tuesday   High: +20.6 Low:  +1.1
# >> Wednesday High: +17.2 Low:  -2.2
# >> Thursday  High:  -1.1 Low:  -7.8
# >> Friday    High: +12.2 Low:  -7.2
# >> Saturday  High: +16.1 Low:  -1.1
# >> Sunday    High:  +3.9 Low:  -2.2
# >> Today     High:  +0.0 Low:  -6.1

Here's a somewhat common case. We have two rows of two columns. Each number is three digits long, so the numbers would line up nicely if it weren't for the fact that one of the numbers is negative, which throws off the alignment of the second row.

puts "x: %d, y: %d\nx: %d, y: %d" % [123, 456, -789, 321]

# >> x: 123, y: 456
# >> x: -789, y: 321

We can fix this without explicit field widths by putting a space between the % sign and field type specifiers. This special flag is similar to the + flag we used earlier, but instead of putting a + in front of positive fields, it adds a space in front of positive numbers so that they take up the same amount of space as negative numbers with the same digit count.

puts "x: % d, y: % d\nx: % d, y: % d" % [123, 456, -789, 321]

# >> x:  123, y:  456
# >> x: -789, y:  321

Sometimes when padding fields, we don't want spaces to be the padding character. For instance, lets look back at our binary number example. A more typical way to show a binary number would be to justify it out to exactly a byte's worth of digits. By specifying a zero followed by a field width, we tell Ruby to format the number with a field width of 8, using zeroes instead of spaces to pad out the missing digits.

"Magic number: %08b" % [23]
# => "Magic number: 00010111"

Let's take a look at some other useful field width tweaks.

When we specify a field width with a string code, we are specifying the minimum field width. If the value is shorter than the width we specify, it will be padded out to fill up the remaining field width. However, what happens when we supply a string which is longer than the field width? In that case, the entire string is inserted. This could be a problem if we are trying to control the number of columns in our output.

"Forecast: %-10s Temp: %d(F)" % ["cloudy with a chance of meatballs", 42]
# => "Forecast: cloudy with a chance of meatballs Temp: 42(F)"

To set a maximum field width for a string field, we can put the width after a period, just like the precision flag for decimal numbers. With a maximum width set, the value will be truncated if it is too long.

"Forecast: %-.10s Temp: %d(F)" % ["cloudy with a chance of meatballs", 42]
# => "Forecast: cloudy wit Temp: 42(F)"

If we want to set both minimum and maximum width we can. This will ensure that the field is a constant width regardless of the length of the string value argument.

"Forecast: %-10.10s Temp: %d(F)" % ["cloudy with a chance of meatballs", 42]
# => "Forecast: cloudy wit Temp: 42(F)"

The precision flag also has a special meaning for the %g code. In this case, it sets the number of significant digits.

dist = 123.0
"Distance in km: %.2g" % [dist]
# => "Distance in km: 1.2e+02"
dist = 2.4e19
"Distance in km: %.2g" % [dist]
# => "Distance in km: 2.4e+19"

Up until now, we've said that Ruby fills in the format string by replacing format codes with values from the argument array, in order. But we can also tell it to use the arguments in arbitrary order. Here's a format string that swaps the order of its argument values:

"low: %2$d, hi: %1$d" % [46, 32]
# => "low: 32, hi: 46"

The $ sign flags that we want to use the preceding number as the source index of the replacement value. The indexes of the arguments are 1-based, rather than zero-based.

Using absolute arguments in format strings is awfully confusing, however, and it's hard to think of a circumstance in which I'd recommend it.

We've been using constant values for the field widths. But what if we want to make field widths configurable? String formats support this as well.

Let's go back to our temperature listing. By replacing the field widths with a star, we tell Ruby to treat one of the value arguments as a field width instead of as a value to be inserted. Then we update the argument list to include field widths before each of the temperature values.

require "./data"
width = 3
DAYS.each_with_index do |day, i|
  puts "%-9s High: %*d Low: %*d" % [day, width, C(HIGHS[i]), width, C(LOWS[i])]
end
# >> Tuesday   High:  20 Low:   1
# >> Wednesday High:  17 Low:  -2
# >> Thursday  High:  -1 Low:  -7
# >> Friday    High:  12 Low:  -7
# >> Saturday  High:  16 Low:  -1
# >> Sunday    High:   3 Low:  -2
# >> Today     High:   0 Low:  -6

We can now modify the field widths from a central location. If we wanted we could also switch the fields to be left-justified by making the number negative.

require "./data"
width = -4
DAYS.each_with_index do |day, i|
  puts "%-9s High: %*d Low: %*d" % [day, width, C(HIGHS[i]), width, C(LOWS[i])]
end
# >> Tuesday   High: 20   Low: 1   
# >> Wednesday High: 17   Low: -2  
# >> Thursday  High: -1   Low: -7  
# >> Friday    High: 12   Low: -7  
# >> Saturday  High: 16   Low: -1  
# >> Sunday    High: 3    Low: -2  
# >> Today     High: 0    Low: -6

To be honest though, I find including the widths in with the value arguments to be confusing. I prefer to make field widths dynamic using ordinary string interpolation of the width variables.

require "./data"
width = -4
DAYS.each_with_index do |day, i|
  puts "%-9s High: %#{width}d Low: %#{width}d" % [day, C(HIGHS[i]), C(LOWS[i])]
end
# >> Tuesday   High: 20   Low: 1   
# >> Wednesday High: 17   Low: -2  
# >> Thursday  High: -1   Low: -7  
# >> Friday    High: 12   Low: -7  
# >> Saturday  High: 16   Low: -1  
# >> Sunday    High: 3    Low: -2  
# >> Today     High: 0    Low: -6

So far, just about everything we've seen has been consistent with C-style printf strings. But as usual, Ruby has some extra tricks up its sleeve. Instead of an array of values, let's move our values into a hash. Then, we'll supply the hash instead of the array as the second argument to the format operator. Next, we update the format string to include named references to values in the hash, instead of relying on array order.

require "./data"
DAYS.each_with_index do |day, i|
  values = {day: day, high: C(HIGHS[i]), low: C(LOWS[i])}
  puts "%<day>-9s High: %<high>3d Low: %<low>3d" % values
end
# >> Tuesday   High:  20 Low:   1
# >> Wednesday High:  17 Low:  -2
# >> Thursday  High:  -1 Low:  -7
# >> Friday    High:  12 Low:  -7
# >> Saturday  High:  16 Low:  -1
# >> Sunday    High:   3 Low:  -2
# >> Today     High:   0 Low:  -6

The end result is a far more readable syntax for format strings. It's just as understandable as a standard Ruby string interpolation, but retains all the power of formatting flags.

There's also a shortcut notation. Let's say we don't care about how a field is formatted, and just want to convert it to a string and insert it as-is. For this case, we can use a % sign with curly braces instead of angle brackets. Note that this form doesn't require a type code at the end, since the argument will always be converted to a string before insertion.

printf "my %{vehicle} is full of %{animal}", vehicle: "hovercraft", animal: "eels"
# >> my hovercraft is full of eels

In effect, this is just like ordinary Ruby string interpolation except for two things: first, it uses % signs instead of # signs. And second, instead of the interpolation happening as soon as the string is defined, it is deferred until we explicitly ask for it, and uses only the values we pass at the point of expansion.

This kind of "controlled interpolation" has some interesting applications. We'll look at one of those applications in the next episode. Happy hacking!

Responses