Strings
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). A String is a mutable sequence of bytes that
carries an encoding, so that those bytes can be read as characters. It is the most used core class in a typical program,
and it has more than a hundred methods, which is why this page is organised by what you want to do with a string:
create it, change it, ask it questions, cut it up, search it, substitute parts of it, split it, change its case, pad it,
format values into it, convert it to other types and scan it from left to right.
Three neighbouring topics have their own pages and are only touched here. The literal syntax (quotes, escapes, %q,
heredocs, ?a) is on Literals. Encodings, force_encoding versus
encode, invalid bytes and Unicode normalisation are on
Encodings and Unicode. Regular expressions, which most of
the searching and substituting methods accept, are on
Regular Expressions. Conversion protocols (to_s versus
to_str, String()) are on Conversions and Coercion, and
the freezing rules are on
Object Lifecycle, Freezing and Copying. For
the symbol type, which is often confused with a string, see Symbols.
Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using
the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off, and
Style/OneClassPerFile off). Lines that end in # => show the value of the expression on that line; they were
produced by running the code, in the Ruby 3.4+ inspect format. Output written by puts or by an error is shown
separately. Two examples carry a RuboCop directive on purpose: the format page below uses literal format strings, and
the error table builds mistakes on purpose.
A String in one page
| Property | What it means |
|---|---|
Bytes plus encoding |
A string stores bytes and an |
Mutable by default |
Most methods ending in |
Comparable |
|
Not a collection |
A string is not |
Not a symbol |
|
Implicit conversion |
|
The class documentation groups its methods by purpose, and so does this page. The methods that exist in several
forms follow one convention: a method with ! modifies the receiver and returns it, or returns nil when nothing
changed; the same name without ! returns a new string. The nil result is the usual surprise, because it makes a
bang method unusable in a chain.
Creating strings
Most strings come from literals, interpolation, format or a conversion method such as to_s. The class method
String.new is for the cases a literal cannot express: a string with a given encoding, a pre-sized buffer, or a mutable
copy of another string.
# frozen_string_literal: true
String.new.encoding # => #<Encoding:BINARY (ASCII-8BIT)>
"".encoding # => #<Encoding:UTF-8>
String.new(encoding: Encoding::UTF_8).encoding # => #<Encoding:UTF-8>
buffer = String.new(capacity: 4096) # => ""
buffer.encoding # => #<Encoding:BINARY (ASCII-8BIT)>
copy = String.new("Dune") # => "Dune"
copy.frozen? # => false
"Dune".frozen? # => true
String.try_convert("Dune") # => "Dune"
String.try_convert(:dune) # => nil
String(42) # => "42"
String(:dune) # => "dune"
String(nil) # => ""
String([1, "two"]) # => "[1, \"two\"]"
tripled = "ab" * 3 # => "ababab"
tripled.length # => 6
String.new("Dune").b.encoding # => #<Encoding:BINARY (ASCII-8BIT)>
Source: String.new
The details worth remembering:
-
String.newwith no argument returns an empty binary string (BINARY, also calledASCII-8BIT), not a UTF-8 one, unlike the empty literal"". Appending UTF-8 text to it still works (the encoding is adjusted on the first append of a non-ASCII string), but a buffer that starts asString.newand receives only ASCII keeps the binary encoding. Passencoding: Encoding::UTF_8or start from+""when the buffer is meant to hold text. -
capacity:only preallocates memory; the string is still empty. It is a hint for building large strings and is rarely needed. -
String.new("Dune")copies the argument (and its encoding), and the copy is not frozen, even if the literal was. -
String.try_convert(obj)callsto_strand returnsnilwhen the object does not respond to it. It is the checked form of the implicit conversion, used by libraries that accept "anything string-like". -
String(obj)(theKernelmethod) triesto_str, thento_s, so it accepts everything, includingnil. -
"ab" * 3repeats a string; a negative count raisesArgumentError(see Errors and warnings).
Source: String.try_convert
Mutating and non-mutating operations
The two ways to add text to a string differ in a way that matters for both correctness and speed. + builds a new
string and leaves both operands alone. << appends to the receiver and returns the receiver, so it can be chained.
+= is not an operator of its own: a += b is a = a + b, so it creates a new string every time and
rebinds the variable, while << keeps the same object.
# frozen_string_literal: true
title = +"Dune"
id = title.object_id
title << " Messiah" # => "Dune Messiah"
title.object_id.equal?(id) # => true
title += "!" # => "Dune Messiah!"
title.object_id.equal?(id) # => false
log = String.new
log << "a" << "b" << 99 # => "abc"
log.concat("d", "e") # => "abcde"
log.prepend(">> ") # => ">> abcde"
log.insert(3, "[") # => ">> [abcde"
log.insert(-1, "]") # => ">> [abcde]"
log.replace("fresh") # => "fresh"
log.clear # => ""
begin
title + 2
rescue TypeError => e
puts "#{e.class}: #{e.message}"
end
count = 2
puts "#{title} #{count}"
puts title + count.to_s
Output:
TypeError: no implicit conversion of Integer into String
Dune Messiah! 2
Dune Messiah!2
Source: String#<<
Points to take from the example:
-
<<returnsself, which is whylog << "a" << "b"works. It accepts anIntegerand appends the character with that code point (99becomes"c"), which surprises people who expect"abc" << 1to append the digit. -
concattakes several arguments at once;prependadds at the front;insert(index, text)inserts before the given index, and a negative index counts from the end and inserts after that position, soinsert(-1, "]")appends. -
replaceswaps the whole content andclearempties the string; both keep the object’s identity. -
There is no implicit conversion in
+:"Dune" + 2raisesTypeError: no implicit conversion of Integer into String. Use interpolation ("Dune #{count}") or callto_syourself. Interpolation callsto_sand never raises for a missing conversion, which is why it is the preferred way to build a message. -
Because
a += bcreates a new object, code that holds another reference to the old string does not see the change;<<is visible through every reference. This is both the point of<<and its main hazard (see Pitfalls).
Source: String#+
Frozen strings, chilled strings and mutability
A frozen string raises FrozenError on any change. Literals become frozen when the file starts with the magic comment
# frozen_string_literal: true; that is the recommended default for every file (RuboCop’s
Style/FrozenStringLiteralComment enforces it). The comment makes the literal itself frozen and de-duplicated: two
occurrences of the same literal in a file are the same object. It does not freeze interpolated strings (since Ruby 3.0 a
"#{x} text" is a new, unfrozen string each time) or strings built by String.new.
# frozen_string_literal: true
literal = "Dune"
literal.frozen? # => true
begin
literal << " Messiah"
rescue FrozenError => e
puts "#{e.class}: #{e.message}"
puts e.receiver.equal?(literal)
end
mutable = +literal
mutable.frozen? # => false
mutable.equal?(literal) # => false
mutable << " Messiah" # => "Dune Messiah"
(+mutable).equal?(mutable) # => true
literal.dup.frozen? # => false
literal.clone.frozen? # => true
literal.clone(freeze: false).frozen? # => false
"#{literal} Messiah".frozen? # => false
String.new("Dune").frozen? # => false
"Dune".equal?("Dune") # => true
suffix = "ne"
(-"Dune").equal?(-"Du#{suffix}") # => true
"Dune".dedup.equal?(-"Dune") # => true
Output:
FrozenError: can't modify frozen String: "Dune"
true
Source: String#+@
The operators and methods that move between the two states:
| Expression | Result |
|---|---|
|
The receiver itself when it is not frozen, otherwise an unfrozen copy. For a chilled string (3.4 and later) it also returns a copy, so |
|
A frozen, de-duplicated string: equal frozen strings share one object. |
|
Freezes the receiver in place and returns it. |
|
An unfrozen copy, without the singleton methods. |
|
A copy that keeps the frozen state and the singleton methods; |
|
Tells whether the string is frozen. A chilled string reports |
FrozenError#receiver gives the string that could not be changed. The message is can't modify frozen String: followed
by the inspect of the string.
Chilled strings (3.4 and later)
Without a magic comment, a string literal is chilled in Ruby 3.4 and later (including 4.0): frozen? returns false
and the string can be changed, but Ruby remembers that the literal is supposed to be frozen one day. The first change
emits a warning of the deprecated category, which is off by default; ruby -w, -W:deprecated,
RUBYOPT=-W:deprecated or Warning[:deprecated] = true turn it on. After the first change the string behaves as a
plain mutable one, so each string warns at most once. The same applies to the string returned by Symbol#to_s in 3.4.
# rubocop:disable Style/FrozenStringLiteralComment
# No magic comment: literals are chilled in Ruby 3.4 and later.
Warning[:deprecated] = true
title = "Dune"
title.frozen? # => false
title << " Messiah" # => "Dune Messiah"
title << "!" # => "Dune Messiah!"
fresh = +"Dune"
fresh << " Messiah" # => "Dune Messiah"
safe = "Dune".dup
safe << " Messiah" # => "Dune Messiah"
# rubocop:enable Style/FrozenStringLiteralComment
Standard error:
final/e04_chilled.rb:7: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
Only the first mutation of the literal warns (line 7); the second append to the same string does not. +"Dune" and
"Dune".dup give a plain mutable string from the start, so they never warn. The command-line switches
--enable-frozen-string-literal (freeze every literal in files without an explicit comment, which finds code that
mutates literals), --disable-frozen-string-literal (the old behaviour, no warning) and --debug-frozen-string-literal
(report where the literal was created) are described on Literals and
Ruby Command Line and Environment. An explicit
# frozen_string_literal: false also keeps literals plain and silent.
Strings are not frozen by default in Ruby 4.0. Feature #20205 (enable frozen_string_literal by default) is still
open, and chilled strings are the migration path. The practical advice is to put the magic comment in every file, to
build strings you intend to change with +"" or String.new, and to run the tests once with -W:deprecated.
Querying a string
The query methods never change the receiver. Most of them have a byte-based twin (byteindex, bytesize,
byteslice) for code that works on binary data or needs offsets to hand to I/O functions.
# frozen_string_literal: true
s = "Café Müller"
s.length # => 11
s.bytesize # => 13
s.empty? # => false
s.ascii_only? # => false
s.include?("Mül") # => true
s.start_with?("Caf") # => true
s.start_with?(/c/i) # => true
s.end_with?("er", "ler") # => true
s.index("é") # => 3
s.byteindex("M") # => 6
s.rindex("l") # => 8
s.index("l", 8) # => 8
s.count("a-z") # => 6
s.count("lm", "^m") # => 2
s =~ /ü/ # => 6
s.match?(/mü/i) # => true
s.match(/(?<first>\p{L}+) (?<last>\p{L}+)/)[:last] # => "Müller"
[("a" <=> "b"), ("b" <=> "a"), ("a" <=> "a".dup), ("a" <=> 1)] # => [-1, 1, 0, nil]
"dune".casecmp("DUNE") # => 0
"dune".casecmp?("DUNE") # => true
"ä".casecmp?("Ä") # => true
"ä".casecmp("Ä") # => 1
"abc".between?("abb", "abd") # => true
"abc".eql?("abc") # => true
"abc".equal?("abc".dup) # => false
"abc".hash.eql?("abc".dup.hash) # => true
s.valid_encoding? # => true
s.unicode_normalized? # => true
Source: String#include?
| Method | Answers |
|---|---|
|
Number of characters. |
|
Encoding facts: |
|
Substring tests. |
|
Position of the first or last match as a character or byte offset, with an optional start offset; |
|
Number of characters matched by one or more character selectors (see Character selectors). |
|
Regexp tests. |
|
Ordering, content equality, content equality without conversion, identity. |
|
Case-insensitive comparison: |
|
From |
|
A hash code consistent with |
start_with? and end_with? take several arguments and return true when any of them matches, which replaces a chain of
||. index with a string argument does a plain substring search; with a Regexp it searches for a match. Counting
characters instead of bytes is the reason "Café Müller".index("é") and "Café Müller".byteindex("M") differ from
what a byte-oriented language would print: "M" is the character at index 5, but byte index 6, because é takes two bytes. Equality has its own page:
Equality, Hashing and Comparison.
Slicing: [], slice and []=
[] (alias slice) is the single method behind most of what other languages call substring, charAt, left and
right. It accepts an index, a start and length, a range (including beginless and endless ranges), a string, or a
regular expression with an optional capture.
# frozen_string_literal: true
s = "The Dispossessed"
s[0] # => "T"
s[-1] # => "d"
s[100] # => nil
s[4, 3] # => "Dis"
s[4, 100] # => "Dispossessed"
s[16, 1] # => ""
s[17, 1] # => nil
s[4..8] # => "Dispo"
s[4...8] # => "Disp"
s[-5..] # => "essed"
s[..2] # => "The"
s["Dis"] # => "Dis"
s["xyz"] # => nil
s[/D\w+/] # => "Dispossessed"
s[/(D)(\w+)/, 2] # => "ispossessed"
s[/(?<word>p\w+)/, "word"] # => "possessed"
s.slice(0, 3) # => "The"
s.byteslice(4, 3) # => "Dis"
"é".byteslice(0) # => "\xC3"
s.chr # => "T"
t = +"The Dispossessed"
t.slice!(0, 4) # => "The "
t.to_s # => "Dispossessed"
t.slice!(/s+/) # => "s"
t.to_s # => "Dipossessed"
t[0, 3] = "Lat" # => "Lat"
t.to_s # => "Latossessed"
t[/\w+\z/] = "Hand" # => "Hand"
t.to_s # => "Hand"
t["Hand"] = "Left Hand" # => "Left Hand"
t.to_s # => "Left Hand"
begin
t["zzz"] = "x"
rescue IndexError => e
puts "#{e.class}: #{e.message}"
end
begin
t[100] = "x"
rescue IndexError => e
puts "#{e.class}: #{e.message}"
end
t.bytesplice(0, 4, "Right") # => "Right Hand"
t.to_s # => "Right Hand"
Output:
IndexError: string not matched
IndexError: index 100 out of string
Source: String#[]
The rules that decide whether a slice is nil, an empty string or a short string:
-
An out-of-range single index (
s[100]) isnil, and a negative index counts from the end (s[-1]is the last character). -
With a start and a length, a start equal to the length returns
""(an empty string at the very end), while a start past the length returnsnil. A length past the end is cut:s[4, 100]returns what is left. -
Ranges follow the same rule for the start, and
s[-5..]ors[..2]are the clear way to write "the last five" and "up to index 2". -
A string argument returns a new copy of itself if it occurs,
nilotherwise. A regular expression returns the whole match, or one capture when a second argument (a number or a name) is given. -
slice!removes the selected part from the receiver and returns it;sliceand[]leave the receiver alone.[]=replaces the part selected by any of the same selectors and returns the assigned value. -
[]=raisesIndexErrorwhen the selector finds nothing:string not matchedfor a string or regexp,index 100 out of stringfor an index. -
byteslicecuts at byte offsets and can return an invalid string ("é".byteslice(0)is the single byte"\xC3");bytesplice(3.2) replaces a byte range. Use them for binary data, not for text. -
Slices are copies: changing the result does not change the original, and
s[0, 3]allocates a newString(see Performance notes).
Source: String#slice!
Searching and extracting
The search methods return positions, parts or MatchData. partition and rpartition always return three strings, which
makes them safe to destructure even when the separator is missing (the missing parts are empty strings).
# frozen_string_literal: true
line = "978-0-306-40615-7 | The Dispossessed | Ursula K. Le Guin"
line.split(" | ") # => ["978-0-306-40615-7", "The Dispossessed", "Ursula K. Le Guin"]
line.partition(" | ") # => ["978-0-306-40615-7", " | ", "The Dispossessed | Ursula K. Le Guin"]
line.rpartition(" | ") # => ["978-0-306-40615-7 | The Dispossessed", " | ", "Ursula K. Le Guin"]
line.partition("zzz") # => ["978-0-306-40615-7 | The Dispossessed | Ursula K. Le Guin", "", ""]
line.scan(/\d+/) # => ["978", "0", "306", "40615", "7"]
line.scan(/(\d)-(\d)/) # => [["8", "0"], ["6", "4"], ["5", "7"]]
line.scan("s").size # => 6
initials = +""
line.scan(/[A-Z]\w+/) { |word| initials << word[0] }
initials.to_s # => "TDULG"
m = line.match(/(?<group>\d+)-(?<publisher>\d+)-/) # => #<MatchData "978-0-" group:"978" publisher:"0">
m[:group] # => "978"
m.post_match[0, 8] # => "306-4061"
line.match?(/\bGuin\z/) # => true
line =~ /Dis/ # => 24
Regexp.last_match(0) # => "Dis"
Regexp.last_match.pre_match.size # => 24
line.index(/\|/) # => 18
line.rindex(/\|/) # => 37
line.delete_prefix("978-").delete_suffix(" Le Guin") # => "0-306-40615-7 | The Dispossessed | Ursula K."
line.start_with?("978", "979") # => true
line.end_with?("Guin") # => true
Source: String#partition
-
splitis covered in Splitting and iterating; here it breaks the line into its three fields. -
scan(pattern)returns every non-overlapping match. With no capture groups it returns an array of strings; with groups it returns an array of arrays, one per match, which is the shape needed forto_h-style parsing. With a block it yields each match instead and returns the receiver. -
matchand=~set the thread-local special variables$~,$1and$&;Regexp.last_matchis the readable way to reach them (RuboCop’sStyle/SpecialGlobalVarsprefers it).match?sets nothing and is faster. -
A named capture is read with
m[:group], andMatchData#pre_matchandpost_matchreturn what came before and after. -
delete_prefixanddelete_suffix(2.5) remove a literal prefix or suffix and are the safe alternatives to asub(/\Afoo/, "")that is easy to get wrong; the!forms returnnilwhen nothing was removed.
Substituting with sub and gsub
sub replaces the first match, gsub all matches. Both take a pattern (a String or a Regexp) and either a
replacement or a block. A string pattern is matched literally: its characters are never special, so "THX1138".gsub('\d+', "00")
changes nothing.
# frozen_string_literal: true
s = "the dispossessed, the left hand of darkness"
s.sub("the", "The") # => "The dispossessed, the left hand of darkness"
s.gsub("the", "The") # => "The dispossessed, The left hand of darkness"
s.gsub(/\bthe\b/, "a") # => "a dispossessed, a left hand of darkness"
s.gsub(/(\w+) (\w+)/, '\2 \1') # => "dispossessed the, left the of hand darkness"
s.gsub(/(?<a>\w+) (?<b>\w+)/, '\k<b> \k<a>') # => "dispossessed the, left the of hand darkness"
s.gsub(/t(h)e/, '<\0|\1>') # => "<the|h> dispossessed, <the|h> left hand of darkness"
s.sub(/dis(?=p)/, '[\&]') # => "the [dis]possessed, the left hand of darkness"
"ab-cd".sub("-", '(\`)') # => "ab(ab)cd"
s.gsub(/\bthe\b/, "the" => "THE") # => "THE dispossessed, THE left hand of darkness"
s.gsub(/[ae]/, "a" => "4", "e" => "3") # => "th3 disposs3ss3d, th3 l3ft h4nd of d4rkn3ss"
s.gsub(/\b\w/, &:upcase) # => "The Dispossessed, The Left Hand Of Darkness"
s.gsub(/\w+/) { |w| w.length > 3 ? w.capitalize : w } # => "the Dispossessed, the Left Hand of Darkness"
s.gsub(/(?<w>\b\w)/) { Regexp.last_match[:w].upcase } # => "The Dispossessed, The Left Hand Of Darkness"
s.gsub("the") { "\\" } # => "\\ dispossessed, \\ left hand of darkness"
s.gsub("the", "\\\\") # => "\\ dispossessed, \\ left hand of darkness"
s.gsub("the", "$&") # => "$& dispossessed, $& left hand of darkness"
s.gsub(/\bthe\b/).with_index.map { |m, i| "#{m}#{i}" } # => ["the0", "the1"]
u = +"hello"
u.sub!(/z+/, "y") # => nil
u.gsub!("l", "L") # => "heLLo"
u.sub!("hello", "x") # => nil
u.sub!("heLLo", "x") # => "x"
"price: 5".sub(/\d+/) { |n| (n.to_i * 2).to_s } # => "price: 10"
"a.b.c".gsub(".", "-") # => "a-b-c"
"a.b.c".gsub(/./, "-") # => "-----"
"THX1138".gsub('\d+', "00") # => "THX1138"
"THX1138".gsub(/\d+/, "00") # => "THX00"
Source: String#gsub
The replacement can be:
-
a string, in which
\1,\2(numbered captures),\k<name>(a named capture),\0or\&(the whole match), a backslash followed by a backtick (the text before) and\'(the text after) are back-references, and\\is one backslash. Because the string literal consumes backslashes first, a back-reference is usually written in single quotes ('\2 \1') or with a doubled backslash in double quotes ("\\2 \\1").$&and$1are not interpreted in a replacement string. -
a hash, in which the matched text is looked up and its value is the replacement. A match with no key is replaced by the empty string, and symbol keys never match. This is a compact way to translate characters or words.
-
a block, which receives the matched text and returns the replacement. Inside the block
$~,$1andRegexp.last_matchdescribe the current match, and the block’s value is converted withto_s. The block form avoids the escaping problems of the string form:s.gsub("the") { "\\" }inserts one backslash, whereas the string"\\\\"is needed for the same result in the string form. -
nothing, in which case
gsubreturns anEnumeratorover the matches.with_indexthen gives numbered replacements, and the same trick exists forscan.
sub! and gsub! return nil when nothing changed, as the example shows (u.sub!(/z+/, "y")). That makes them unsafe
at the end of a method whose result is used, and it makes x = s.gsub!(...) wrong whenever no replacement happens;
prefer the non-bang methods unless you need to change a shared string. RuboCop’s Style/RedundantRegexpArgument suggests
a plain string when the pattern is a literal with no special characters, which also avoids the regular expression engine.
For translating single characters, tr (next section) is faster and clearer than gsub.
Character selectors: tr, delete, squeeze and strip
Several methods take character selectors, a small language of their own that is similar to a regular-expression
character class but is not one. A selector is a string: the characters in it are the set; a leading ^ negates the set;
a hyphen between two characters is a range ("a-d"); and a backslash escapes ^, - and \. These methods accept
selectors: tr, tr_s, count, delete, squeeze, and, since Ruby 4.0, strip, lstrip and rstrip
(Feature #21552).
With more than one selector (in count, delete, squeeze and the 4.0 strip family), the selected characters are those
common to all the selectors, so delete("a-c", "b-d") removes only b and c.
# frozen_string_literal: true
"hello".tr("el", "ip") # => "hippo"
"hello".tr("a-y", "b-z") # => "ifmmp"
"hello".tr("^l", "*") # => "**ll*"
"hello".tr("el", "x") # => "hxxxo"
"hello".tr("l", "") # => "heo"
"hello".tr_s("l", "r") # => "hero"
"aabbcc".tr_s("ab", "x") # => "xcc"
"abracadabra".delete("a") # => "brcdbr"
"abracadabra".delete("^bc") # => "bcb"
"abracadabra".delete("a-c", "b-d") # => "araadara"
"aaabbb ccc".squeeze # => "ab c"
"aaabbb ccc".squeeze("a-b") # => "ab ccc"
"abracadabra".count("a-c") # => 8
"abracadabra".count("^a") # => 6
"a-b^c\\d".delete("\\-") # => "ab^c\\d"
"a-b^c\\d".delete("\\^") # => "a-bc\\d"
"a-b^c\\d".delete("\\\\") # => "a-b^cd"
" Dune \n".strip # => "Dune"
" Dune \n".lstrip # => "Dune \n"
" Dune \n".rstrip # => " Dune"
"xxDunexx".strip("x") # => "Dune"
"xxDunexx".lstrip("x") # => "Dunexx"
"xxDunexx".rstrip("x") # => "xxDune"
"--[Dune]--".strip("-", "^[") # => "[Dune]"
"--[Dune]--".strip("^a-zA-Z") # => "Dune"
" Dune\t".strip(" ") # => "Dune\t"
"\0 Dune \0".strip # => "Dune"
"Dune\r\n".chomp # => "Dune"
"Dune\n\n".chomp # => "Dune\n"
"Dune\r\n\r\n".chomp("") # => "Dune"
"Dune".chomp("ne") # => "Du"
"Dune".chop # => "Dun"
"Dune\r\n".chop # => "Dune"
file = +"dune.rb"
file.delete_suffix!(".rb") # => "dune"
file.delete_suffix!(".rb") # => nil
file.to_s # => "dune"
Source: Character Selectors
| Method | Behaviour |
|---|---|
|
Translates each character in |
|
Like |
|
Removes every selected character. |
|
Collapses runs of the same character into one; without arguments, every run; with selectors, only the selected characters. |
|
Counts the selected characters. |
|
Remove leading and trailing whitespace. Whitespace is any mix of NUL, tab, line feed, vertical tab, form feed, carriage return and space. With selectors (4.0) they remove the selected characters from the ends instead, so |
|
Removes one trailing record separator: |
|
Removes the last character, or a trailing |
|
Remove a literal prefix or suffix, once. |
Two traps. First, with selectors, strip(" ") removes only spaces, so the tab at the end of " Dune\t" stays; use plain
strip for "any whitespace". Second, strip with selectors changes what a call with arguments means on older Ruby: on
3.4 and earlier strip accepts no argument and raises ArgumentError, so code that has to run on several versions
should use delete_prefix/delete_suffix or a regular expression (sub(/\A-+|-+\z/, "")).
Source: String#strip
Splitting and iterating
split cuts a string into an array; lines and each_line cut at line boundaries and keep the separators unless told
otherwise; chars, bytes, codepoints and grapheme_clusters break a string into its units. The each_* forms
return an Enumerator without a block, which avoids building the whole array.
# frozen_string_literal: true
csv = "dune,,messiah,,"
csv.split(",") # => ["dune", "", "messiah"]
csv.split(",", -1) # => ["dune", "", "messiah", "", ""]
csv.split(",", 2) # => ["dune", ",messiah,,"]
" a b\tc\n".split # => ["a", "b", "c"]
awk_mode = " "
" a b".split(awk_mode) # => ["a", "b"]
" a b".split(/ /) # => ["", "", "a", "", "b"]
"a1b22c".split(/\d+/) # => ["a", "b", "c"]
"a1b22c".split(/(\d+)/) # => ["a", "1", "b", "22", "c"]
"2026-10-18".split("-").map(&:to_i) # => [2026, 10, 18]
text = "one\ntwo\r\nthree\n"
text.lines # => ["one\n", "two\r\n", "three\n"]
text.lines(chomp: true) # => ["one", "two", "three"]
text.each_line("o").to_a # => ["o", "ne\ntwo", "\r\nthree\n"]
text.each_line.with_index(1).map { |l, i| "#{i}:#{l.chomp}" } # => ["1:one", "2:two", "3:three"]
"a\n\nb\n\n\nc".each_line("").to_a # => ["a\n\n", "b\n\n", "c"]
"héllo".chars # => ["h", "é", "l", "l", "o"]
"héllo".bytes.first(3) # => [104, 195, 169]
"héllo".codepoints # => [104, 233, 108, 108, 111]
"héllo".each_char.to_a.size # => 5
"🇪🇸".length # => 2
"🇪🇸".grapheme_clusters.length # => 1
"e\u0301".grapheme_clusters.length # => 1
"abc".each_char.map(&:ord) # => [97, 98, 99]
"abc".sum # => 294
Source: String#split
Rules for split:
-
Trailing empty fields are dropped unless a negative limit is given:
"dune,,messiah,,".split(",")has three elements,split(",", -1)keeps all five. A positive limit stops after that many fields and leaves the rest unsplit in the last. -
A single space as separator (or no argument) is "awk mode": leading whitespace is ignored and runs of any whitespace separate fields.
split(/ /)is a plain regular expression and keeps the empty fields. -
A regular expression with a capture group adds the separators to the result.
-
With a block,
splityields each field and returns the receiver (use it to avoid the array). -
split("")andsplit(//)return the characters, butcharssays it better;"🇪🇸".charshas two code points whilegrapheme_clustershas one, which is what a user sees as a single character.
lines and each_line accept a separator string (each_line("o") splits after each "o"), the empty string for paragraph
mode (splits at blank lines), and chomp: true to drop the separators. Use each_line on a large input read into memory,
or better, iterate an IO directly (see IO, Files and Directories).
sum is a trivial byte checksum (the sum of the bytes modulo 65536 by default); it is not suitable for integrity checks, for which
the Digest library is the tool. unpack and unpack1 decode binary strings (see the Array#pack page of the
reference); they belong with binary data and encodings.
Case mapping
upcase, downcase, capitalize and swapcase (and their ! forms) use full Unicode case mapping by default for
UTF-8, UTF-16, UTF-32 and ISO-8859 strings. That means the result can have a different length ("straße".upcase is
"STRASSE"), that the title-case digraph is respected ("dž".capitalize is "Dž"), and that the mapping is reversible
only for plain cases ("Straße".downcase.upcase does not return the original). Context-dependent mapping, such as the
Greek final sigma rule, is not supported.
# frozen_string_literal: true
title = "the left hand of darkness"
title.upcase # => "THE LEFT HAND OF DARKNESS"
title.capitalize # => "The left hand of darkness"
"Dune MESSIAH".swapcase # => "dUNE messiah"
"DUNE".downcase.capitalize # => "Dune"
"dž".capitalize # => "Dž"
"straße".upcase # => "STRASSE"
"ÀÉÎ".downcase # => "àéî"
"ÀÉÎ".downcase(:ascii) # => "ÀÉÎ"
"Türkiye".upcase(:turkic) # => "TÜRKİYE"
"I".downcase(:turkic) # => "ı"
"Straße".downcase(:fold) # => "strasse"
"Straße".casecmp?("STRASSE") # => true
"Straße".casecmp("STRASSE") # => 1
(+"Dune").upcase! # => "DUNE"
(+"DUNE").upcase! # => nil
shout = +"dune"
shout.upcase! # => "DUNE"
shout.upcase! # => nil
shout.to_s # => "DUNE"
title.split.map(&:capitalize).join(" ") # => "The Left Hand Of Darkness"
Source: Case Mapping
The optional options are symbols that can be combined:
| Option | Effect |
|---|---|
|
Only |
|
Turkish and Azeri rules for the dotted and dotless I ( |
|
Only for |
casecmp? uses folding, so "Straße".casecmp?("STRASSE") is true, whereas casecmp handles ASCII only and returns a
number. A title like "the left hand of darkness".split.map(&:capitalize).join(" ") is the common word-by-word
capitalisation; the shelf gem wraps the same idea in the title_case refinement (see The strings of the shelf gem).
The bang forms return nil when nothing changed, as (+"DUNE").upcase! shows.
Padding and alignment
ljust, rjust and center pad to a width counted in characters, with a space by default or with a given pad string
that is repeated and cut as needed. They return a new string and never truncate; a width smaller than the length returns
the string unchanged. An empty pad string raises ArgumentError (zero width padding).
# frozen_string_literal: true
"Dune".ljust(10, ".") # => "Dune......"
"Dune".rjust(10) # => " Dune"
"Dune".center(11, "*") # => "***Dune****"
"Dune".center(3) # => "Dune"
"7".rjust(3, "0") # => "007"
"Dune".ljust(10, "<>") # => "Dune<><><>"
"日本".ljust(6, ".") # => "日本...."
"日本".bytesize # => 6
rows = [["Dune", 1965], ["Emma", 1815], ["Hyperion", 1989]]
width = rows.map { |title, _| title.length }.max
rows.each { |title, year| puts "#{title.ljust(width)} | #{year.to_s.rjust(6)}" }
puts "Shelf".center(20, "=")
Output:
Dune | 1965
Emma | 1815
Hyperion | 1989
=======Shelf========
Source: String#ljust
Width counts characters, not terminal cells: "日本".ljust(6, ".") pads with four dots even though each of those two
characters is two cells wide in a monospaced terminal, and combining marks count as characters too. For aligned
tables with wide text, compute widths with a library or pad using format and accept the limit. The next section covers
format, which is the better tool for tables of numbers.
Formatting with format, sprintf and String#%
Kernel#format (alias sprintf) builds a string from a template and arguments; Kernel#printf and IO#printf write the
result instead of returning it; String#% is the operator form ("%s is %d" % ["Dune", 3]), which takes a single
argument or an array of arguments. A format specification has the form %[flags][width][.precision]type, where the type
is the only required part.
# frozen_string_literal: true
# Deliberate illustration: literal format strings and arguments.
# rubocop:disable Style/FormatStringToken, Style/FormatString, Style/RedundantFormat, Lint/FormatParameterMismatch
format("%s has %d copies", "Dune", 3) # => "Dune has 3 copies"
format("%5d|%-5d|%05d", 42, 42, 42) # => " 42|42 |00042"
format("%+d % d %+d", 5, 5, -5) # => "+5 5 -5"
format("%.2f %8.3f %-8.1f|", 3.14159, 2.5, 2.5) # => "3.14 2.500 2.5 |"
format("%e %g %g", 12_345.678, 12_345.678, 0.00001234) # => "1.234568e+04 12345.7 1.234e-05"
format("%x %X %#x %o %#o %b %#b %08b", 255, 255, 255, 8, 8, 5, 5, 5) # => "ff FF 0xff 10 010 101 0b101 00000101"
format("%c%c %%", 65, "z") # => "Az %"
format("%10s|%-10s|%.3s", "Dune", "Dune", "Messiah") # => " Dune|Dune |Mes"
format("%p %s", "Dune", "Dune") # => "\"Dune\" Dune"
format("%p %s", nil, nil) # => "nil "
format("%2$s %1$s", "world", "hello") # => "hello world"
format("%<title>s (%<year>d)", title: "Dune", year: 1965) # => "Dune (1965)"
format("%{title} (%{year})", title: "Dune", year: 1965) # => "Dune (1965)"
format("%-12<title>s|%6.2<price>f", title: "Dune", price: 9.5) # => "Dune | 9.50"
format("%*d|%-*d|", 6, 42, 6, 42) # => " 42|42 |"
format("%.*f", 1, 2.25) # => "2.2"
format("%d", 3.99) # => "3"
format("%d", "0x1f") # => "31"
format("%s", [1, "a"]) # => "[1, \"a\"]"
format("%f", 1r / 3) # => "0.333333"
format("%.20f", 0.1) # => "0.10000000000000000555"
format("%.0f %.0f %.0f", 0.5, 1.5, 2.5) # => "0 2 2"
[0.5, 1.5, 2.5].map(&:round) # => [1, 2, 3]
format("%.2f %.2f %.2f", 2.675, 8.345, 1.005) # => "2.68 8.34 1.00"
[2.675, 8.345, 1.005].map { |f| f.round(2) } # => [2.68, 8.35, 1.01]
format("%.2f", 0.1 + 0.2) # => "0.30"
puts "%s is %d" % ["Dune", 3]
puts "%05.1f%%" % 99.5
puts "%s" % [[1, 2]]
puts "%s and %s" % %w[a b]
puts "%{a}-%{b}" % { a: 1, b: 2 }
$stdout.printf("%-6s%3d\n", "Emma", 2)
[
-> { format("%d", "abc") },
-> { format("%d", nil) },
-> { format("%s %s", "only one") },
-> { format("%z", 1) },
-> { format("%<a>s", { b: 1 }) },
-> { format("%d %<a>d", 1, a: 2) }
].each do |attempt|
attempt.call
rescue ArgumentError, TypeError, KeyError => e
puts "#{e.class}: #{e.message}"
end
# rubocop:enable Style/FormatStringToken, Style/FormatString, Style/RedundantFormat, Lint/FormatParameterMismatch
Output:
Dune is 3
099.5%
[1, 2]
a and b
1-2
Emma 2
ArgumentError: invalid value for Integer(): "abc"
TypeError: can't convert nil into Integer
ArgumentError: too few arguments
ArgumentError: malformed format string - %z
KeyError: key<a> not found
ArgumentError: named<a> after unnumbered(1)
Source: Format Specifications
Types
| Type | Meaning |
|---|---|
|
Decimal integer. A float is truncated toward zero ( |
|
Binary, octal, hexadecimal integer. A negative number prints as a two’s complement prefixed with |
|
Decimal floating point, default precision 6; accepts |
|
Scientific notation. |
|
Shorter of |
|
Hexadecimal floating point. |
|
A character: an |
|
|
|
|
|
A literal percent sign. |
Flags, width and precision
| Flag or part | Effect |
|---|---|
space |
Reserve a space for the sign of a non-negative number. |
|
Alternate form: a |
|
Always show the sign. |
|
Left-justify within the width. |
|
Pad with zeros instead of spaces. |
|
Use the n-th argument ( |
width |
Minimum number of characters. |
|
For integers the minimum number of digits, for |
Named references
%<name>s takes the next specification’s type and reads the value from a hash argument by key; %{name} inserts the value
with to_s and accepts no type, flags or width. They read much better than a row of %s, and they are safe against
argument reordering, but they cannot be mixed with * or n$: format("%d %<a>d", 1, a: 2) raises ArgumentError
(named<a> after unnumbered(1)). A missing key raises KeyError.
The errors a bad format string can raise are in the example: ArgumentError: too few arguments, malformed format string - %z,
invalid value for Integer(): "abc", TypeError: can't convert nil into Integer, and KeyError: key<a> not found. With
$DEBUG set, too many arguments raise ArgumentError as well; otherwise extra arguments are ignored.
Money and rounding
format("%.2f", amount) is for display. Binary floats cannot hold 0.1 exactly (format("%.20f", 0.1) prints
0.10000000000000000555), and the rounding of decimal-looking values is not what a C or Python programmer expects:
format("%.2f", 2.675) is "2.68" and format("%.2f", 8.345) is "8.34", while Float#round(2) gives 2.68 and
8.35, and the exact binary values would give 2.67 and 8.35. Halves of exactly representable numbers go to the even
digit in format ("%.0f" of 0.5, 1.5 and 2.5 is 0 2 2) and away from zero in Float#round (1 2 3). The rule is
that the two methods disagree and neither is a money rounding rule: keep money in integer cents or
Rational/BigDecimal (see Numbers), and format only at the edge.
RuboCop has opinions about all of this: Style/FormatString prefers format to String#%, Style/FormatStringToken
prefers annotated tokens (%<name>s) over a run of %s, and Style/RedundantFormat rejects a format call whose
arguments are all literals. The example turns those three off, plus Lint/FormatParameterMismatch for the deliberate
mistakes, with a comment, because showing the specifications requires literal calls.
Source: Kernel#format
Conversions to and from strings
The conversions from a string are lenient (to_i, to_f, to_r, to_c: read what looks like a number from the start
and ignore the rest, returning zero when nothing matches) or strict (Integer(), Float(), Rational(): raise unless
the whole string is a valid number). Prefer the strict form for input that must be valid; exception: false returns
nil instead of raising.
# frozen_string_literal: true
"42".to_i # => 42
"42abc".to_i # => 42
"abc".to_i # => 0
" 42 ".to_i # => 42
"1_000".to_i # => 1000
"ff".to_i(16) # => 255
"0b101".to_i(0) # => 5
"0x1f".hex # => 31
"777".oct # => 511
"3.7abc".to_f # => 3.7
"1e3".to_f # => 1000.0
".5".to_f # => 0.5
"1/3".to_r # => (1/3)
"0.75".to_r # => (3/4)
"1+2i".to_c # => (1+2i)
Integer("42") # => 42
Integer("0x1f", 16) # => 31
Integer("abc", exception: false) # => nil
Float("3.5") # => 3.5
Float("3.5x", exception: false) # => nil
name = "hello world"
name.to_sym # => :"hello world"
"A".ord # => 65
65.chr # => "A"
233.chr(Encoding::UTF_8) # => "é"
name.to_s.equal?(name) # => true
:dune.to_s # => "dune"
nil.to_s # => ""
[1, "a"].to_s # => "[1, \"a\"]"
12_345.to_s.reverse.scan(/\d{1,3}/).join(",").reverse # => "12,345"
255.to_s(2) # => "11111111"
Source: String#to_i
-
to_i(base)accepts bases 2 to 36 and, with base 0, honours the0b,0oand0xprefixes. Underscores between digits are allowed ("1_000".to_iis 1000); surrounding spaces are ignored. -
hexandoctareto_i(16)andto_i(8)with prefix handling. They exist for historical reasons;to_i(16)is clearer. -
to_sym(aliasintern) creates or finds the symbol; any string works (:"hello world"). -
ordreturns the code point of the first character andInteger#chris its inverse;chrwithout an encoding returns a byte for values below 256 and raisesRangeErrorfor larger ones unless an encoding is given. -
to_sreturns the receiver itself for aString(and a copy for a subclass);to_stris the implicit conversion. The protocols are explained on Conversions and Coercion. -
inspectreturns a printable literal form (used bypand by IRB),dumpadditionally escapes non-ASCII as\usequences, andundumpreversesdump.Kernel#putscallsto_s.
Successors, ranges and ordering
succ (alias next) returns the "next" string the way an odometer does: it increments the rightmost alphanumeric and
carries to the left. upto and string ranges use it. Strings compare with <=> byte by byte, which gives surprising
orders for mixed case and for numbers inside text.
# frozen_string_literal: true
"a".succ # => "b"
"az".succ # => "ba"
"zz".succ # => "aaa"
"a9".succ # => "b0"
"Zz".succ # => "AAa"
"1.9".succ # => "2.0"
"-9".succ # => "-10"
"".succ # => ""
"a".upto("e").to_a # => ["a", "b", "c", "d", "e"]
"08".upto("11").to_a # => ["08", "09", "10", "11"]
("a".."e").step(2).to_a # => ["a", "c", "e"]
("aa".."ad").to_a # => ["aa", "ab", "ac", "ad"]
("a".."e").cover?("cc") # => true
("a".."e").include?("cc") # => false
%w[b10 a2 a10 B1].sort # => ["B1", "a10", "a2", "b10"]
%w[b10 a2 a10 B1].sort_by(&:downcase) # => ["a10", "a2", "B1", "b10"]
%w[b10 a2 a10 B1].sort_by { |v| [v[0].downcase, v[1..].to_i] } # => ["a2", "a10", "B1", "b10"]
%w[pear Apple fig].max_by(&:length) # => "Apple"
"abc".eql?("abc") # => true
"a".ord # => 97
Source: String#succ
-
"az".succis"ba"and"zz".succis"aaa"; with no alphanumerics, the rightmost character code is incremented. -
"a".upto("e")and("a".."e")iterate withsuccuntil the end value is reached or the length would exceed it."08".upto("11")keeps the zero padding, which is useful for numbered file names. -
Range#include?on a string range iterates (it isfalsefor"cc"in"a".."e"because"cc"is not generated bysucc), whereascover?compares with<=>and istrue. Pickcover?for "is it between" tests. See Ranges. -
The default order is byte order, so uppercase sorts before lowercase and
"a10"before"a2". For human sorting usesort_bywith a key that splits the text and the number, as in the example, orcasecmp.
StringScanner
StringScanner (the strscan library, a default gem, loaded with require "strscan") reads a string from left to
right, matching a pattern at the current position and moving on. It is the tool for lexers and small parsers where a
single regular expression becomes unreadable, and it is faster than repeatedly slicing the string because it never
copies the unread part.
# frozen_string_literal: true
require "strscan"
ss = StringScanner.new("978-0-306-40615-7 | The Dispossessed | 1974")
ss.scan(/\d{3}/) # => "978"
ss.pos # => 3
ss.scan("-") # => "-"
ss.scan(/x+/) # => nil
ss.check(/\d+/) # => "0"
ss.pos # => 4
ss.peek(5) # => "0-306"
ss.skip(/[\d-]+/) # => 13
ss.scan_until(/\|/) # => " |"
ss.pre_match # => "978-0-306-40615-7 "
ss.skip(/\s+/) # => 1
ss.scan(/[^|]+/) # => "The Dispossessed "
ss.unscan.pos # => 20
ss.scan(/[^|]+/) # => "The Dispossessed "
ss.scan(/\|\s*(?<year>\d+)/) # => "| 1974"
ss[:year] # => "1974"
ss[0] # => "| 1974"
ss.eos? # => true
ss.reset.pos # => 0
ss.scan_integer # => 978
ss.getch # => "-"
ss.exist?(/\|/) # => 15
ss.terminate.eos? # => true
# A small lexer for a search query such as: title:"Left Hand" author:Guin -draft
class QueryLexer
Token = Data.define(:type, :text)
def initialize(input)
@scanner = StringScanner.new(input)
end
def tokens
list = []
while (token = next_token)
list << token
end
list
end
private
def next_token
@scanner.skip(/\s+/)
return if @scanner.eos?
phrase || term || exclusion || unexpected
end
def phrase
return unless @scanner.scan(/(?<field>\w+):"(?<value>[^"]*)"/)
Token.new(:phrase, "#{@scanner[:field]}=#{@scanner[:value]}")
end
def term
return unless @scanner.scan(/(?<field>\w+):(?<value>\S+)/)
Token.new(:term, "#{@scanner[:field]}=#{@scanner[:value]}")
end
def exclusion
Token.new(:exclude, @scanner[:word]) if @scanner.scan(/-(?<word>\S+)/)
end
def unexpected
raise ArgumentError, "unexpected #{@scanner.getch.inspect} at #{@scanner.charpos - 1}"
end
end
QueryLexer.new('title:"Left Hand" author:Guin -draft').tokens.each { |token| p token }
begin
QueryLexer.new("title:Dune ?").tokens
rescue ArgumentError => e
puts "#{e.class}: #{e.message}"
end
Output:
#<data QueryLexer::Token type=:phrase, text="title=Left Hand">
#<data QueryLexer::Token type=:term, text="author=Guin">
#<data QueryLexer::Token type=:exclude, text="draft">
ArgumentError: unexpected "?" at 11
Source: StringScanner
The scanner has a stored string, a byte position (pos) and the match data of the last operation:
| Method | Behaviour |
|---|---|
|
Matches the pattern at the position, advances and returns the match; |
|
Matches anywhere ahead, advances past the match and returns everything from the position to its end. |
|
Like |
|
Look without advancing. |
|
Look at or consume a number of characters or bytes. |
|
Matches and converts an integer at the position (a recent addition to |
|
Position control and inspection. |
|
Data of the last match, including named captures ( |
|
The stored string, replacement and append. |
The lexer in the example is a standard shape: skip whitespace, try each token pattern in turn, and raise at the first
character no pattern accepts. Two details matter. A pattern passed to scan is anchored at the current position
(it behaves like \G), which is what makes the scanner a tokenizer and not a search. And pos is a byte position:
on multibyte text charpos gives the character offset to show in an error message.
The strings of the shelf gem
The running shelf gem builds strings in several places: Book#to_s interpolates a title and an author, Isbn#initialize
normalises its input with delete("-") and match?, Loan#describe builds messages, and the title_case refinement adds
a method to String without a global monkey patch. The example below adds a small helper module with the string work a
catalogue tool needs: parsing a pipe-separated line, building a slug, hyphenating an ISBN-13 and printing aligned rows.
# frozen_string_literal: true
require "shelf"
using Shelf::Refinements::TitleCase
# Helpers that build strings for the shelf catalogue.
module Labels
module_function
# "The Left Hand of Darkness!" -> "the-left-hand-of-darkness"
def slug(title)
title.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
end
# "9780306406157" -> "978-0-306-40615-7"
def hyphenate(digits)
digits.sub(/\A(\d{3})(\d)(\d{3})(\d{5})(\d)\z/, '\1-\2-\3-\4-\5')
end
def row(book, width)
format("%<title>s %-22<author>s %<isbn>s",
title: book.title.ljust(width), author: book.author.name, isbn: hyphenate(book.isbn.to_s))
end
def parse(line)
isbn, title, author = line.split("|", 3).map(&:strip)
Shelf::Book.new(isbn: Shelf::Isbn.new(isbn), title: title.title_case, author: Shelf::Author.new(author))
end
end
lines = <<~CATALOG.lines(chomp: true)
978-0-306-40615-7 | the dispossessed | Ursula K. Le Guin
9780140449136 | the left HAND of darkness | Ursula K. Le Guin
CATALOG
catalog = Shelf::Catalog.new
lines.each { |line| catalog.add(Labels.parse(line)) }
width = catalog.map { |book| book.title.length }.max
catalog.each { |book| puts Labels.row(book, width) }
catalog.map { |book| Labels.slug(book.title) } # => ["the-dispossessed", "the-left-hand-of-darkness"]
Labels.slug(" The Dispossessed: An Ambiguous Utopia! ") # => "the-dispossessed-an-ambiguous-utopia"
Labels.hyphenate("9780306406157") # => "978-0-306-40615-7"
catalog.first.to_s # => "The Dispossessed by Ursula K. Le Guin"
catalog.first.isbn.inspect # => "#<data Shelf::Isbn digits=\"9780306406157\">"
Output:
The Dispossessed Ursula K. Le Guin 978-0-306-40615-7
The Left Hand Of Darkness Ursula K. Le Guin 978-0-140-44913-6
Source: String#gsub
A few things the example shows in practice:
-
A squiggly heredoc (
<<~) strips the common indentation, andlines(chomp: true)turns the block into an array of lines. -
split("|", 3).map(&:strip)limits the number of fields, so a title containing a pipe would still produce three fields, and strips each of them. -
The slug pipeline is a chain of non-bang methods:
downcase,gsubof every non-alphanumeric run, thendelete_prefix/delete_suffixfor the ends. A chain of bang methods would break at the firstnil. -
hyphenateuses a fixed 3-1-3-5-1 layout for illustration. Real ISBN hyphenation depends on registrant ranges and needs a table, which is whyIsbnstores the plain digits and formats only on output. -
using Shelf::Refinements::TitleCaseactivates the refinement for the rest of the file only; the second title was typed in mixed case and came out asThe Left Hand Of Darkness. See Refinements and Monkey Patching.
Performance notes
The costs in string code are almost always allocations: every new String is an object the garbage collector must track
and eventually free. The example counts the objects a block allocates with GC.stat(:total_allocated_objects), which is a
deterministic measure (timings vary by machine). The counts are approximate: a few objects of overhead and small
differences between versions are normal.
# frozen_string_literal: true
# Counts the objects a block allocates (an approximation: the numbers vary by a few between runs and versions).
def allocations
before = GC.stat(:total_allocated_objects)
yield
GC.stat(:total_allocated_objects) - before
end
def with_plus_equals(count)
text = +""
count.times { text += "x" }
text
end
def with_append(count)
text = +""
count.times { text << "x" }
text
end
def with_interpolation(count)
text = +""
count.times { |i| text << "n#{i}," }
text
end
def with_join(count)
Array.new(count) { |i| "n#{i}," }.join
end
big = "x" * 1000
allocations { 1 } # => 1
allocations { with_plus_equals(1000) } # => 1004
allocations { with_append(1000) } # => 2
allocations { with_interpolation(1000) } # => 1992
allocations { with_join(1000) } # => 1997
allocations { big.chars } # => 1003
allocations { big.each_char { |c| c } } # => 1001
allocations { big.bytes } # => 2
allocations { big.each_byte { |b| b } } # => 1
allocations { 1000.times { big.start_with?("xx") } } # => 0
allocations { 1000.times { big[0, 2] == "xx" } } # => 1001
allocations { 1000.times { -"a literal" } } # => 0
allocations { 1000.times { "a literal".dup } } # => 1000
Source: GC.stat
What the numbers say:
-
a = a + band+=allocate one string per iteration (about 1000), whereas<<appends to one buffer (about two objects in total). In a loop that builds a long string,<<(orArray#join, orStringIO) is the right choice; a one-offa + bis fine. -
Interpolation inside
<<costs about two objects per iteration, because the interpolated text and the converted number are built before they are appended. Building the pieces in an array and callingjoinis about equal. For a handful of values, interpolation is the clearest and is as fast as any alternative. -
charsandeach_charallocate one string per character, whilebytesallocates one array of integers (small integers are not heap objects) andeach_byteallocates nothing. If you only need to test bytes or code points, prefereach_byteoreach_codepoint. -
start_with?allocates nothing, whiles[0, 2] == "xx"allocates the slice; prefer the predicate methods. Likewisematch?overmatchor=~when you only need a boolean. -
-"literal"and"literal".freezeof an already frozen literal allocate nothing, whereasdupallocates a string every time. With the magic comment, a literal inside a loop is already a shared object. -
Pre-sizing a buffer (
String.new(capacity: n)) can save reallocations when the final size is known, but measure before using it.
The measurement tools (Benchmark, benchmark-ips, ObjectSpace, memory profilers) and the garbage collector are covered on
Performance, Benchmarking and Profiling and
Garbage Collection and Memory.
Errors and warnings
# frozen_string_literal: true
# Deliberate illustration: each lambda is a mistake that raises.
# rubocop:disable Style/StringConcatenation, Style/FormatStringToken, Lint/FormatParameterMismatch
attempts = {
"no implicit to_s in +" => -> { "Dune " + 2 },
"nil receiver" => -> { nil + "Dune" },
"wrong method" => -> { "Dune".shout },
"negative repeat" => -> { "Dune" * -1 },
"empty pad" => -> { "Dune".ljust(10, "") },
"bad arity" => -> { "Dune".center },
"Integer() is strict" => -> { Integer("12abc") },
"frozen literal" => -> { "Dune".upcase! },
"invalid bytes" => -> { "caf\xE9".split("a") },
"too few format args" => -> { format("%s and %s", "Dune") },
"missing hash key" => -> { format("%<name>s", title: "Dune") }
}
attempts.each do |label, attempt|
attempt.call
rescue StandardError => e
puts "#{label}: #{e.class}: #{e.message}"
end
# rubocop:enable Style/StringConcatenation, Style/FormatStringToken, Lint/FormatParameterMismatch
Output:
no implicit to_s in +: TypeError: no implicit conversion of Integer into String
nil receiver: NoMethodError: undefined method '+' for nil
wrong method: NoMethodError: undefined method 'shout' for an instance of String
negative repeat: ArgumentError: negative argument
empty pad: ArgumentError: zero width padding
bad arity: ArgumentError: wrong number of arguments (given 0, expected 1..2)
Integer() is strict: ArgumentError: invalid value for Integer(): "12abc"
frozen literal: FrozenError: can't modify frozen String: "Dune"
invalid bytes: ArgumentError: invalid byte sequence in UTF-8
too few format args: ArgumentError: too few arguments
missing hash key: KeyError: key<name> not found
Source: String#center
| Exception or warning | When | Remedy |
|---|---|---|
|
|
Interpolate or call |
|
A method that does not exist; the message names the class, and |
Check the receiver; in 3.4 and later the method name is quoted with |
|
Invalid argument values or arity. |
Validate the argument before the call. |
|
A strict conversion of text that is not a number. |
Rescue it, or pass |
|
A pattern-matching method such as |
|
|
A change to a frozen string (including a literal under the magic comment). |
Work on |
|
|
Check with |
|
|
Provide the key. |
|
Transcoding or combining incompatible encodings. |
See the encodings page. |
Warning |
A chilled literal was mutated with the |
Use |
Pitfalls
# frozen_string_literal: true
a = b = +"Dune"
a << " Messiah"
p b
shared = Array.new(2, +"x")
shared.first << "y"
p shared
separate = Array.new(2) { +"x" }
separate.first << "y"
p separate
p "abc"[3], "abc"[3, 1], "abc"[4, 1]
puts "an index of 0 is truthy" if "Dune".index("D")
title = "Dune"
puts title == :Dune, title == :Dune.to_s
p "é".match?(/\w/), "é".match?(/\p{Word}/), "é".match?(/[[:alpha:]]/)
Output:
"Dune Messiah"
["xy", "xy"]
["xy", "x"]
nil
""
nil
an index of 0 is truthy
false
true
false
true
true
Source: String#[]
-
Aliasing.
a = b = +"Dune"makes both names point to the same string, and<<through one name is visible through the other.Array.new(2, +"x")shares one string between both slots, whereas the block form creates two. This is the usual cause of "my list of strings all changed" bugs. -
Boundary indexes.
"abc"[3]isnilbut"abc"[3, 1]is"", and"abc"[4, 1]isnil; code that treats both as "empty" misses the difference. -
Zero is truthy.
"Dune" =~ /D/returns the index0, which is a true value in Ruby; code ported from languages where 0 is false misbehaves. Usematch?for a boolean. -
\wis ASCII only. In Ruby a regular expression\wdoes not matché. Use\p{Word},\p{L}orfor Unicode text (see Regular Expressions). -
Strings and symbols.
"Dune" == :Duneisfalse; convert one side deliberately. Hash keys are not interchangeable either. -
Bang methods return
nil.name.strip!.upcaseraisesNoMethodErrorwhennamehad no whitespace. -
Encoding.
String.newis binary; building text from it and from user input mixes encodings. Create buffers with+"". -
gsubbackslashes. A replacement"\\1"is a back-reference, and a literal backslash needs four characters in the string form; use the block form. -
Mutating arguments. A method that calls
<<orupcase!on its argument changes the caller’s string and raisesFrozenErrorfor a frozen one. Return a new string instead.
Where older material is outdated
Books and articles about Ruby strings were written for versions before 3.4, and several statements changed. The differences that touch strings:
| Statement in older material | Ruby 4.0 | Notes |
|---|---|---|
"String literals are frozen by default since 3.4." |
Not frozen. They are chilled: mutable, with a warning only under |
The magic comment or |
|
|
3.3 changed the receiver text ( |
|
|
3.4 |
|
|
New in 4.0 (Feature #21552). |
|
The result is chilled since 3.4 |
Making that result frozen is under discussion for a later release. |
|
Redundant with the magic comment |
RuboCop’s |
|
Returns |
Unchanged since 1.8, but still a frequent bug. |
The version notes for the String API itself:
| Version | Change |
|---|---|
2.3 |
The |
2.4 |
Full Unicode case mapping and |
2.5 |
|
3.0 |
Interpolated strings are no longer frozen by the magic comment. Most |
3.1 |
|
3.2 |
|
3.3 |
|
3.4 |
Chilled strings ( |
4.0 |
|
Summary
-
A
Stringis mutable bytes with an encoding;lengthcounts characters andbytesizecounts bytes. -
Put
# frozen_string_literal: truein every file, build mutable strings with+""orString.new, and expect a chilled-string warning under-W:deprecatedwhere you do not. -
<<appends in place,+and+=create new strings; interpolation avoids theTypeErrorof+. -
[],slice,slice!and[]=share one selector language: index, start and length, range, string, regexp. -
sub/gsubtake a pattern and a string, hash or block; prefer the block form to avoid backslash traps, and remember that bang methods returnnilwhen nothing changed. -
Character selectors power
tr,delete,squeeze,countand, in 4.0,strip. -
formathandles padding, precision and named references; it rounds the exact binary value, so keep money out of floats. -
StringScanneris the right tool for tokenizing; count allocations, not just time, when tuning string code.