3
\$\begingroup\$

I have written a method, which checks for Palindrome and attached it to the String-class.

Here's the code:

class String
  def is_palindrome
    for i in 0..(self.size / 2 - 1)
      if self[i].downcase != self[-(i + 1)].downcase
        return false
      end
    end
    true
  end
end

puts "Anna".is_palindrome
puts "civic".is_palindrome
puts "Kayak".is_palindrome
puts "madam".is_palindrome
puts "level".is_palindrome
puts "Testing".is_palindrome
puts "LoremIpsum".is_palindrome
puts "demonstration".is_palindrome
puts "Mister".is_palindrome
puts "Otto".is_palindrome

Could my code become improved concerning Ruby-idiomatic? I mean: Would an experienced Ruby-developer have done it different?

Am I taking enough advantage of the possibilities Ruby provides? Or can it become improved?

What's your opinion about extending the String-class with a is_palindrome-method? Is it a good idea or should I have done it as a function?

\$\endgroup\$

2 Answers 2

4
\$\begingroup\$

The code looks just fine.

design

Consider defining a new class, with its own name, which does what String does plus it has other fancy features such as a palindrome predicate.

As written, it will be hard for engineers to know if the String they have in hand is the regular kind or the fancy kind. Names help to clarify such details.

test suite

The OP code runs "manual" tests which ask you to eyeball the results and judge if they are correct.

Better to use a proper testing framework, perhaps Test::Unit. Then the test suite knows the right answer, and can automatically display {red, green} bar as appropriate.

\$\endgroup\$
2
\$\begingroup\$

Single-quoted strings

Most communities have developed standardized community style guides. In Ruby, there are multiple such style guides. One popular such style guide is the one found at https://rubystyle.guide/. All the different community style guides in Ruby agree on the basics (e.g. indentation is 2 spaces), but they might disagree on more specific points (e.g. single quotes or double quotes).

In your case, you are consistent with your string literals, so that is good!

I, personally prefer to use single-quoted strings whenever there is no string interpolation. That way, it is immediately obvious that no string interpolation is taking place.

So, I, personally, would not write

"Anna"

but instead I would write

'Anna'

And so on …

Note that it is perfectly fine to use double quoted strings if you otherwise needed to use escapes, e.g. if you had some something like

puts "michael's Ruby exercise"

that reads much better than

puts 'michael\'s Ruby exercise'

So in this case, double quotes would be preferred.

Frozen string literals

Immutable data structures and purely functional code are always preferred, unless mutability and side-effects are required for clarity or performance. In Ruby, strings are always mutable, but there is a magic comment you can add to your files (also available as a command-line option for the Ruby engine), which will automatically make all literal strings immutable:

# frozen_string_literal: true

It is generally preferred to add this comment to all your files.

In future versions of Ruby, files that don't have a # frozen_string_literal: magic comment will slowly default to frozen strings, see Feature #20205 Enable frozen_string_literal by default

self is the implicit receiver

self is the implicit receiver of any message send that does not explicitly specify a receiver. Therefore, it is not required to specify self as the explicit receiver in most cases.

Therefore,

self.size

should just be

size

Prefer trailing modifier if / unless

For conditionals whose body only contains a single expression, prefer the trailing modifier form.

Replace

if self[i].downcase != self[-(i + 1)].downcase
  return false
end

with

return false if self[i].downcase != self[-(i + 1)].downcase

Prefer each over for

You should never use for/in in Ruby. Always prefer to use each.

In fact, for/in is just syntactic sugar for each anyway, but with weird scoping rules: for/in leaks the loop variable to the outside scope and/or clobbers an existing variable with the same name!

So,

for i in 0..(self.size / 2 - 1)
  return false if self[i].downcase != self[-(i + 1)].downcase
end

should instead be

(0..(size / 2 - 1)).each do |i|
  return false if self[i].downcase != self[-(i + 1)].downcase
end

Linting

You should run some sort of linter or static analyzer on your code. Rubocop is a popular one, but there are others.

Rubocop was able to detect all of the style violations I pointed out above (plus some more), and also was able to autocorrect most of the ones it detected.

Let me repeat that: I have just spent two pages pointing out how to correct tons of stuff that you can actually correct within milliseconds at the push of a button. I have set up my editor such that it automatically runs Rubocop with auto-fix as soon as I hit "save".

Here's what the result of the auto-fix looks like:

# frozen_string_literal: true

class String
  def is_palindrome
    (0..((size / 2) - 1)).each do |i|
      return false if self[i].downcase != self[-(i + 1)].downcase
    end
    true
  end
end

puts 'Anna'.is_palindrome
puts 'civic'.is_palindrome
puts 'Kayak'.is_palindrome
puts 'madam'.is_palindrome
puts 'level'.is_palindrome
puts 'Testing'.is_palindrome
puts 'LoremIpsum'.is_palindrome
puts 'demonstration'.is_palindrome
puts 'Mister'.is_palindrome
puts 'Otto'.is_palindrome

And here are the offenses that Rubocop could not automatically correct:

Inspecting 1 file
C

Offenses:

palindrome.rb:6:3: C: Style/DocumentationMethod: Missing method documentation comment.
  def is_palindrome ...
  ^^^^^^^^^^^^^^^^^
palindrome.rb:6:7: C: Naming/PredicateName: Rename is_palindrome to palindrome?. (https://rubystyle.guide#bool-methods-qmark)
  def is_palindrome
      ^^^^^^^^^^^^^

1 file inspected, 2 offenses detected

It is a good idea to set up your tools such that the linter is automatically run when you paste code, edit code, save code, commit code, or build your project, and that passing the linter is a criterium for your CI pipeline.

In my editor, I actually have multiple linters and static analyzers integrated so that they automatically always analyze my code, and also as much as possible automatically fix it while I am typing. This can sometimes be annoying (e.g. I get over 20 notices for your original code, lots of which are duplicates because several different tools report the same problem), but it is in general tremendously helpful. It can be overwhelming when you open a large piece of code for the first time and you get dozens or hundreds of notices, but if you start a new project, then you can write your code in a way that you never get a notice, and your code will usually be better for it.

However, even by simply hitting "Save", my editor applies a series of automatic fixes which brings the number of notices down substantially. Running Rubocop as described above, further reduces this, and as mentioned, lots of these are duplicates because I have multiple different linters and analyzers configured.

So, there are only two offenses left, both of which are trivial to fix, especially in an editor with refactoring support.

Predicate methods end with a question mark

In Ruby, a question mark ? is a legal character at the end of a method identifier. Predicates, i.e., methods that answer a yes/no question, should be named with a question mark at the end.

So, by only this rule, is_palindrome should be named is_palindrome?. However, see the next rule as well.

Predicate methods should not have a prefix

In Ruby, predicate methods should not be prefixed with is, has, can, etc.

Together with the previous rule, this means, is_palindrome should be named palindrome?

That was easy!

Note that all we did so far was either done automatically for us by the editor or Rubocop's auto-correct feature, or we were blindly following instructions such as renaming methods. We did not yet have to think at all.

This is one of the great things about using automatic code formatters, linters, and static analyzers: you don't have to think about all the simple stuff. Where do I put my parentheses? How do I indent my code? What are the naming conventions? All of that is taken care of by the tools, I don't have to worry about it and can instead focus on the important stuff: what should the code actually do?

Another advantage of following community style guides and using community tools is that, if my code looks like everybody else's code, and everybody else's code looks like my code, it is much easier to get someone else to help me with my code.

There's a linter called Standard Ruby, which is essentially a set of standard configurations for Rubocop that cannot be changed. This takes all the discussions and decisions out of the whole topic.

So, just by asking Rubocop to automatically fix our code for us, and by stupidly and without thinking following what Rubocop told us to do, we ended up with the following code:

# frozen_string_literal: true

class String
  def palindrome?
    (0..(size / 2 - 1)).each do |i|
      return false if self[i].downcase != self[-(i + 1)].downcase
    end
    true
  end
end

puts 'Anna'.palindrome?
puts 'civic'.palindrome?
puts 'Kayak'.palindrome?
puts 'madam'.palindrome?
puts 'level'.palindrome?
puts 'Testing'.palindrome?
puts 'LoremIpsum'.palindrome?
puts 'demonstration'.palindrome?
puts 'Mister'.palindrome?
puts 'Otto'.palindrome?

Which I would argue is already more readable than what we started off with, and is a lot more conformant with community style guidelines.

Vertical whitespace

I, personally, would separate the final return value from the rest of the method body by a blank line:

def palindrome?
  (0..(size / 2 - 1)).each do |i|
    return false if self[i].downcase != self[-(i + 1)].downcase
  end

  true
end

No Monkey Patching

It is generally bad form to "monkey patch" (i.e. modify) existing modules and classes.

In this case, I don't see a need for monkey patching, and palindrome? could just be a module function instead.

In fact, it is generally good form to have a module that is named the same as your project to enclose all your code in, just to keep it separate from everybody else's code. Something like this:

module ::Palindrome
  module_function

  def palindrome?(str)
    (0..(str.size / 2 - 1)).each do |i|
      return false if str[i].downcase != str[-(i + 1)].downcase
    end

    true
  end
end

Now, thanks to Module#module_function, if someone really, really wants to have the method available globally, they can Module#include the Palindrome module as a mixin:

include Palindrome

palindrome?('Otto')

But, they can also just use

Palindrome.palindrome?('Otto')

IFF monkey patching is absolutely, positively, required, there are a couple of things we can do to make it nicer.

Imagine you are a maintenance programmer and you see a piece of unfamiliar code for the very first time which looks like this:

'Otto'.palindrome?

You are wondering where this palindrome? method that you've never heard of comes from, so you ask Ruby herself by using the Method#owner method:

method = 'Otto'.method(:palindrome?)

method.owner
#=> String

Hmm … that's confusing. There is no method named palindrome? mentioned in the documentation of the String class.

Use a clearly named mixin to mark the extension

So, the first thing we can do is to wrap the method into a mixin whose name makes it clear that we are monkey patching a core class and that allows a reader to make an educated guess where to find the documentation:

module ::Palindrome
  module StringExtension
    def palindrome?
      (0..(size / 2 - 1)).each do |i|
        return false if self[i].downcase != self[-(i + 1)].downcase
      end

      true
    end
  end
end

class ::String
  include ::Palindrome::StringExtension
end

Now, if we try the same thing as above, we get a more useful response:

method.owner
#=> Palindrome::StringExtension

And the reader can now "guess" that the documentation for this method, as well as its source code, can probably be found in a file named palindrome/string_extension.rb within a library or Gem called palindrome, at least assuming that the author followed standard Ruby naming and directly layout guidelines.

Note: we could have also used Method#source_location method to find where the method is defined. However, this method does not always work, so our poor maintenance programmer may be forgiven for not trying it in the first place. In particular, on most Ruby implementations, Method#source_location only works for methods that are defined in Ruby.

On the most popular Ruby implementation, YARV, the core String class is written in C, which means source_location wouldn't work for methods defined in the String core class. So, a maintenance programmer might not even try to use source_location on a method they find on a string.

Wrap the extension in a Refinement

Refinements are Ruby's way of controlling the scope of monkey patching. Refinements are lexically scoped, so the monkey patches are only visible within the lexical scope (script, module definition, or eval string) where they are explicitly activated.

module ::Palindrome
  module StringExtension
    def palindrome?
      (0..(size / 2 - 1)).each do |i|
        return false if self[i].downcase != self[-(i + 1)].downcase
      end

      true
    end
  end

  refine ::String do
    import_methods ::Palindrome::StringExtension
  end
end

This gives the user the option: they can either use the Palindrome::StringExtension mixin to monkey patch String themselves, if they really want to make this monkey patch available globally. Alternatively, they can use the Refinement.

The nice thing about Refinements is that they are only visible if you explicitly activate them:

# Here, the method does not exist:
'Anna'.palindrome?
# in `<main>': undefined method `palindrome?' for an instance of String (NoMethodError)

# I need to explicitly activate the Refinement:
using ::Palindrome

'Anna'.palindrome?
#=> true

Above, I activated the Refinement in the script scope, so it will be visible in the entire script. However, I can also activate the Refinement in class or module scope:

class ::Foo
  using ::Palindrome

  def self.foo = 'Anna'.palindrome?
end

class ::Bar
  def self.bar = 'Anna'.palindrome?
end

::Foo.foo
#=> true

::Bar.bar
# in `bar': undefined method `palindrome?' for an instance of String (NoMethodError)

Functional code

Personally, I prefer to avoid mutation, explicit iteration, and side-effects as much as possible.

I will just show two options that don't use explicit iteration. Each of these have their individual tradeoffs, and I don't claim either of those is better than yours. They're just "different", and hopefully at least thought-provoking.

A palindrome is the same when read forward and backward

This is the idea that your code is based on, but it can also be expressed more directly:

def palindrome?
  word = downcase
  word == word.reverse
end

This is a very clear and concise expression of the idea.

Compared to your solution, this is lacking the short-circuiting behavior. In other words, this will always examine every character of the word, even if the first and last character are already different.

I would argue that the simple solution is preferred, unless you have positive proof that it causes a performance bottleneck.

The first and last letters are the same and the rest is a palindrome

This is an alternate way of expressing what a palindrome is. This lends itself nicely to a recursive solution:

def palindrome?
  word = downcase

  return true if word.empty?
  word.first == word.last && word[1...-1].palindrome?
end

This does have the short-circuiting behavior of your solution, but, it would blow the stack for very long words. There is a way to make it tail-recursive, but that makes the method quite a bit longer. The simple transformation would also lose the short-circuiting behavior, and re-introducing that would make it even longer.

Also, it wouldn't actually help because Ruby does not have Proper Tail Calls or Proper Tail Recursion.

I do not think this is a solution I would put out into the world. This is just to demonstrate how the problem can be looked at from a different angle.

Documentation

There are now only three things left that Rubocop complains about: the fact that Palindrome, StringExtension, and palindrome? have no documentation.

The nice thing about Ruby's convention of naming predicate methods with a question mark, is that the method is almost fully self-documenting already: we know because of the question mark that it returns a Boolean, and the name pretty much tells us what it does. So, really, the method documentation is just a formality:

# Tests whether +self+ is a palindrome.
#
# = Performance guarantee
#
# * The method performs at most +self.size / 2+ iterations.
# * The method short-circuits at the first mismatch.
#
# @return [Boolean] wether or not +self+ is a palindrome.
def palindrome?
  # …
end

The documentation allows us to document our performance guarantees.

I will leave the documentation for the StringExtension mixin as well as the Palindrome Refinement up to you. It would probably be a good idea to point out that the method will not be automatically monkey patched, and that two options are available to either monkey patch it globally or activate the Refinement.

Testing

It is good that you included test cases with the code! This allows us to more confidently change the code and still being sure we didn't break anything.

It would be even better to move the test code into an actual test suite. There are many different testing frameworks out there for Ruby. I, personally, am a fan of RSpec, but for this simple case, we can use minitest, which is a Default Gem that ships as part of Ruby and thus does not need to be installed:

require 'minitest/autorun'

describe ::Palindrome do
  before do
    ::String.include(::Palindrome::StringExtension)
  end

  it { _('Anna').must_be          :palindrome? }
  it { _('civic').must_be         :palindrome? }
  it { _('Kayak').must_be         :palindrome? }
  it { _('madam').must_be         :palindrome? }
  it { _('level').must_be         :palindrome? }

  it { _('Testing').wont_be       :palindrome? }
  it { _('LoremIpsum').wont_be    :palindrome? }
  it { _('demonstration').wont_be :palindrome? }
  it { _('Mister').wont_be        :palindrome? }

  it { _('Otto').must_be          :palindrome? }
end

Additional corner cases

I would add some more tests for corner / edge cases, the two I can think oo are:

  • The empty string should be a palindrome: it { _('').must_be :palindrome? }
  • A single character string should be a palindrome: it { _('a').must_be :palindrome? }
\$\endgroup\$

Not the answer you're looking for? Browse other questions tagged or ask your own question.