LaunchKit
← All posts

A palindrome checker in 7 lines, and what String.include really does

13 min read by Mehdi Farsi

Watch on YouTube

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:

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:

#ruby #video #strings

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.