A palindrome checker in 7 lines, and what String.include really does
Text version
A palindrome reads the same forwards and backwards once you ignore case, spaces and punctuation. In Ruby that fits in seven lines, and every one of those lines hides a decision worth understanding.
The seven lines
module Palindrome
def palindrome?
tmp = downcase.gsub(/[^[[:alnum:]]]/, "")
tmp == tmp.reverse
end
end
String.include Palindrome
"lol".palindrome? # => true
Three steps. downcase normalizes case, so Madam and madam compare equal. The gsub
deletes every character that is not a letter or a digit, so spaces, commas, colons and slashes
stop mattering. Then the cleaned string is compared to its reverse.
The method is defined without a receiver, which means downcase is called on self, the
string the method was invoked on. Nothing is mutated: gsub and reverse both return new
strings.
Here is the behavior on the cases from the video, each value verified by running the code:
"racecar".palindrome? # => true
"madam".palindrome? # => true
"not a palindrome".palindrome? # => false
"hello".palindrome? # => false
"..., . madam, . ...".palindrome? # => true
"11/11/11 11:11:11".palindrome? # => true
"A man, a plan, a canal: Panama".palindrome? # => true
"A man, a plan, a canal — Panama!".palindrome? # => true
"".palindrome? # => true
That last-but-one string is the interesting one: it contains an em-dash, a non-ASCII character
that is neither a letter nor a digit, and the gsub removes it exactly like it removes the
commas and the exclamation mark.
The regex
/[^[[:alnum:]]]/ looks like a typo. It is not. Read it from the inside out:
[[:alnum:]]is the POSIX bracket expression for "alphanumeric". POSIX classes are written[:alnum:]and are only valid inside a character class, so they always appear wrapped in a second pair of brackets.- The leading
^negates that class.[^[[:alnum:]]]means "any character that is not alphanumeric".
gsub replaces every match with the empty string, so what survives is letters and digits only.
Why \w is not a drop-in replacement
\w is tempting and shorter, but it differs in two ways that both change the result.
First, \w includes the underscore:
"snake_case".gsub(/[^[[:alnum:]]]/, "") # => "snakecase"
"snake_case".gsub(/[^\w]/, "") # => "snake_case"
"_".match?(/[[:alnum:]]/) # => false
"_".match?(/\w/) # => true
Second, in Ruby \w is ASCII-only by default, while [[:alnum:]] is Unicode-aware on a UTF-8
string. So \w treats accented letters as punctuation and deletes them:
"café".gsub(/[^[[:alnum:]]]/, "") # => "café"
"café".gsub(/[^\w]/, "") # => "caf"
"é".match?(/[[:alnum:]]/) # => true
"é".match?(/\w/) # => false
Silently dropping every accented character from the input is not a difference you want to discover in production.
String.include Palindrome
The usual way to add a method to a core class is to reopen it with class String.
String.include Palindrome does the same job as a plain method call, because Module#include
has been public since Ruby 2.1. Before that it was private and you had to write
String.send(:include, Palindrome).
It returns the receiver, which is what lets you chain it:
String.include Palindrome # => String
The module is inserted into the ancestor chain directly above the superclass, below the class itself:
String.ancestors # => [String, Comparable, Object, Kernel, BasicObject]
String.include Palindrome
String.ancestors # => [String, Palindrome, Comparable, Object, Kernel, BasicObject]
String.include?(Palindrome) # => true
Position in that array is the lookup order, and it decides who wins a name clash: String
comes first, so a method defined directly on String shadows the module's version. Try to
override upcase from an included module and nothing happens:
module Palindrome
def upcase
"from the module"
end
end
String.include Palindrome
"lol".upcase # => "LOL"
String.instance_method(:upcase).owner # => String
This is a feature, not a limitation. An included module can add methods, but it cannot
accidentally hijack an existing one. If you genuinely need to override, you have to reopen the
class or use prepend.
The accent trap
The classic French palindrome breaks the seven-line version:
"Ésope reste ici et se repose".palindrome? # => false
The reason is visible as soon as you print the cleaned string and its reverse:
tmp = "Ésope reste ici et se repose".downcase.gsub(/[^[[:alnum:]]]/, "")
tmp # => "ésoperesteicietserepose"
tmp.reverse # => "esoperesteicietsereposé"
É downcases to é, not to e. The string is a palindrome to a human reading it, because
French speakers treat é and e as the same letter, but they are two distinct code points and
== compares code points.
The fix is to decompose the accented characters and drop the accents. Unicode NFD normalization
splits é into a base e plus a combining acute accent:
"é".length # => 1
"é".unicode_normalize(:nfd).length # => 2
"é".unicode_normalize(:nfd).chars # => ["e", "́"]
Combining marks are in Unicode category Mn, so \p{Mn} removes them:
module Palindrome
def palindrome?
tmp = downcase
.unicode_normalize(:nfd)
.gsub(/\p{Mn}/, "")
.gsub(/[^[[:alnum:]]]/, "")
tmp == tmp.reverse
end
end
String.include Palindrome
"Ésope reste ici et se repose".palindrome? # => true
"élu par cette crapule".palindrome? # => true
"A man, a plan, a canal: Panama".palindrome? # => true
"hello".palindrome? # => false
One honest note: the \p{Mn} step is redundant here. Combining marks are not alphanumeric, so
the existing [^[[:alnum:]]] filter already deletes them. Keeping it makes the intent explicit
for the next reader, and it protects the method if the alnum filter is ever changed.
In a Rails app you have another option, ActiveSupport::Inflector.transliterate, which maps
accented characters to their ASCII approximations. It goes further than NFD, handling
characters like ø that have no decomposition, but it is Latin-oriented and replaces anything
it cannot map with ?:
require "active_support/inflector"
ActiveSupport::Inflector.transliterate("ø") # => "o"
ActiveSupport::Inflector.transliterate("Ω") # => "?"
ActiveSupport::Inflector.transliterate("Ésope reste") # => "Esope reste"
So it is a poor fit for text that may contain Greek, Cyrillic or CJK.
Edge cases worth a decision
The seven lines answer three questions by accident. Decide whether you agree with the answers.
The empty string is a palindrome. After the gsub, "" equals "".reverse, so the method
returns true. Mathematically correct, occasionally surprising in a validation.
"".palindrome? # => true
" ".palindrome? # => true
"a".palindrome? # => true
A string of pure punctuation cleans down to "" and returns true for the same reason.
Single characters are palindromes. Same logic, and almost never worth special-casing.
Digits count. [[:alnum:]] is letters and numbers, which is why the timestamp case from
the video passes:
"11/11/11 11:11:11".palindrome? # => true
"11/11/11 11:11:11".gsub(/[^[[:alnum:]]]/, "") # => "111111111111"
If you only want word palindromes, swap [[:alnum:]] for [[:alpha:]]. Be aware that this
does not make the timestamp false: it cleans down to "", which is still a palindrome by the
rule above. The two classes only disagree when digits sit inside real letters:
"a1bb2a".downcase.gsub(/[^[[:alnum:]]]/, "") # => "a1bb2a"
"a1bb2a".downcase.gsub(/[^[[:alpha:]]]/, "") # => "abba"
"a1bb2a" is not a palindrome, "abba" is.
Performance
reverse allocates a full copy of the cleaned string, which invites the classic optimization:
walk two pointers inward and compare in place. A third variant swaps tmp[i] for
tmp.getbyte(i), which returns an Integer instead of a one-character String.
Here are all three, with the shared clean-up pulled into its own method so it can be timed on its own:
require "benchmark"
module Palindrome
def cleaned
downcase.gsub(/[^[[:alnum:]]]/, "")
end
def palindrome?
tmp = cleaned
tmp == tmp.reverse
end
def palindrome_two_pointer?
tmp = cleaned
i = 0
j = tmp.length - 1
while i < j
return false unless tmp[i] == tmp[j]
i += 1
j -= 1
end
true
end
def palindrome_getbyte?
tmp = cleaned
i = 0
j = tmp.bytesize - 1
while i < j
return false unless tmp.getbyte(i) == tmp.getbyte(j)
i += 1
j -= 1
end
true
end
end
String.include Palindrome
All three agree with the original on every case above.
One input for every measurement in this section: a 100,000-character palindrome that is already
clean, meaning lowercase, ASCII and alphanumeric. cleaned therefore strips nothing and returns
an identical string, which keeps the clean-up a constant across the variants instead of a hidden
variable:
half = "abcdefghij" * 5_000
subject = half + half.reverse
subject.length # => 100000
subject.cleaned == subject # => true
subject.palindrome? # => true
n = 200
Benchmark.bm(20) do |x|
x.report("clean-up only") { n.times { subject.cleaned } }
x.report("reverse + ==") { n.times { subject.palindrome? } }
x.report("two-pointer") { n.times { subject.palindrome_two_pointer? } }
x.report("getbyte") { n.times { subject.palindrome_getbyte? } }
x.report("compare step only") { n.times { subject == subject.reverse } }
end
Ruby 4.0.5, arm64-darwin25. Absolute times depend on the machine; the ratios are what travel:
user system total real
clean-up only 0.268292 0.002799 0.271091 ( 0.271581)
reverse + == 0.270188 0.002629 0.272817 ( 0.273609)
two-pointer 1.568495 0.017136 1.585631 ( 1.592742)
getbyte 0.765169 0.005538 0.770707 ( 0.772668)
compare step only 0.001348 0.001069 0.002417 ( 0.002436)
Three results matter here.
The comparison is not the bottleneck. The whole palindrome? method costs 0.274 seconds for
200 passes, and the downcase.gsub clean-up that every variant shares accounts for 0.272 of
that. The compare step on its own is 0.0024 seconds, under 1 percent of the method and roughly
100 times cheaper than the clean-up feeding it. That last row is the smallest number in the
table and the one that moves most between runs, so read it as an order of magnitude rather than
a measurement. Either way, optimizing the compare is optimizing the wrong line.
The two-pointer loop is a pessimization. 1.59 seconds against 0.274, about 5.8 times slower
than the version it was meant to replace. Subtract the shared clean-up from both and the loop
alone costs 1.32 seconds against 0.0024, roughly 500 times the reverse plus == it replaces.
The reason is tmp[i]: indexing a String in Ruby returns a new one-character String, so the
"allocation-free" version allocates two objects per iteration. Count them with
GC.stat(:total_allocated_objects) on the same input:
def allocs
GC.start
before = GC.stat(:total_allocated_objects)
yield
GC.stat(:total_allocated_objects) - before
end
# warm up: the first call of each variant, and the first call of allocs itself,
# pay for one-time lazy initialization
5.times { subject.cleaned; subject.palindrome?; subject.palindrome_two_pointer?; subject.palindrome_getbyte? }
allocs { subject.cleaned }
allocs { subject.cleaned } # => 5
allocs { subject.palindrome? } # => 6
allocs { subject.palindrome_getbyte? } # => 5
allocs { subject.palindrome_two_pointer? } # => 100005
Five allocations are the shared clean-up. reverse adds exactly one, the reversed copy.
getbyte adds none, because it returns an Integer. The two-pointer version adds 100,000: two
objects per iteration, one for tmp[i] and one for tmp[j], across 50,000 iterations.
getbyte beats tmp[i] and still loses to reverse. At 0.773 seconds it is about 2.8 times
the full seven-line method, and its loop alone, 0.50 seconds, is roughly 200 times the cost of
the reverse compare. It also only works on ASCII: on UTF-8 input it compares bytes, not
characters, and a two-byte character read from the wrong end will not match itself.
There is exactly one case where a two-pointer scan genuinely wins: an early mismatch. Flip the first character and time the compare step with the clean-up taken out. The compare step is small enough to need a higher iteration count and a warm-up to measure at all:
def two_pointer_compare(tmp)
i = 0
j = tmp.length - 1
while i < j
return false unless tmp[i] == tmp[j]
i += 1
j -= 1
end
true
end
mismatch = "z" + subject[1..]
mismatch.length # => 100000
mismatch.palindrome? # => false
m = 2_000
# warm up: the first few thousand 100,000-character copies pay one-time page-fault costs
3.times { m.times { mismatch == mismatch.reverse }; m.times { two_pointer_compare(mismatch) } }
Benchmark.bm(24) do |x|
x.report("compare step, reverse") { m.times { mismatch == mismatch.reverse } }
x.report("compare step, two-pointer") { m.times { two_pointer_compare(mismatch) } }
end
user system total real
compare step, reverse 0.006413 0.000076 0.006489 ( 0.006489)
compare step, two-pointer 0.000279 0.000001 0.000280 ( 0.000280)
About 20 times faster over 2,000 passes, because the loop exits after one comparison while
reverse still copies 100,000 characters first. Now run the same mismatched string through the
full methods, 200 passes as before:
Benchmark.bm(24) do |x|
x.report("full palindrome?") { n.times { mismatch.palindrome? } }
x.report("full two-pointer") { n.times { mismatch.palindrome_two_pointer? } }
end
user system total real
full palindrome? 0.266427 0.003654 0.270081 ( 0.271493)
full two-pointer 0.270118 0.003765 0.273883 ( 0.274801)
Indistinguishable. Scaled to 200 passes, the compare step the early exit skipped is worth 0.0006 seconds, a quarter of a percent of the 0.271 the method takes. The early exit only pays if you can hand the comparison a string that is already clean. For ordinary inputs, the seven-line version is the right trade, and it is also the faster one.
Monkey patching versus refinements
String.include Palindrome is global. Every string in the process, in your code and in every
gem you load, gains palindrome?. A refinement scopes the change instead:
module StringPalindrome
refine String do
def palindrome?
tmp = downcase.gsub(/[^[[:alnum:]]]/, "")
tmp == tmp.reverse
end
end
end
using StringPalindrome
"racecar".palindrome? # => true
String.instance_methods.include?(:palindrome?) # => false
String itself is untouched, which is why instance_methods does not list the method. The
refinement is active only in the lexical scope where using was called, from that point to the
end of the file. Code defined earlier in the file, or in another file, does not see it:
module StringPalindrome
refine String do
def palindrome?
tmp = downcase.gsub(/[^[[:alnum:]]]/, "")
tmp == tmp.reverse
end
end
end
class Elsewhere
def try
"racecar".palindrome?
rescue NoMethodError => e
e.message
end
end
using StringPalindrome
"racecar".palindrome? # => true
Elsewhere.new.try
# => "undefined method 'palindrome?' for an instance of String"
respond_to? follows the same lexical rule, returning true inside the scope and false
outside it.
The rule of thumb: use a refinement when the method is a convenience for your own code and you
do not want it leaking into gems, or when the name is generic enough to risk a collision with
something else defining it later. Use include on the core class when the method is part of
your application's shared vocabulary and every file should have it, and accept that you now own
that name process-wide. palindrome? happens to be free today (ActiveSupport 8.1 does not
define it), but nothing reserves it for you, and if a gem later adds its own, the last
definition loaded silently wins.
Related quizzes
Two short quizzes on String behavior that bites in the same way:
Comments
No comments yet. Be the first.