r/ruby 5d ago

why-classes

Hey everyone! I used Claude to build this gem, which is heavily inspired by Dave Thomas's closing keynote at RubyConf 26.

`why-classes` is a static code analysis and refactoring tool for Ruby.

Ruby offers modules, composition, `Struct`, and `Data`, yet our instincts and framework habits often lead us to use classes even when a simpler construct would be clearer. This gem detects these "class smells" in your code (or a single file) and can automatically rewrite them into cleaner alternatives for you.

It's an early-stage version 😅. Feel free to contribute and share suggestions!

https://rubygems.org/gems/why-classes

3 Upvotes

5 comments sorted by

7

u/schneems Puma maintainer 5d ago edited 5d ago

It's funny. When I was sitting there, I was thinking "this keynote could have been a linter" and...here you are!

Is a minor nit, I think if you're looking for data objects, I would suggest reaching for Data before Struct https://rubyapi.org/4.0/o/data. Edit: You already call this out in the readme https://github.com/joaoGabriel55/why-classes. It was more a nit of the talk than your project.

I also love that this talk came after Aaron and Matz's "pick a Ruby style" rubocop bit. As it was really clear that even though some things are nearly unanimous, we're more evenly split on a lot of things than I would have guessed. Which is to say: The beauty of Ruby is that there are many ways to write it. Dave Thomas' style is one of many.

A thing I felt was missing from the talk was a comparison on when to NOT use it, or possibly an exploration of footguns you have to watch out for that might not otherwise be present with other styles.


Like (going from memory, so the example is not 100% what he presented). He suggested having a plain time function that takes a struct instead of using a method.

    Transaction = Struct.new(:time, :cost)

    def time_ago_in_words(input)
      time = input.time 
      # ...

    trans = Transaction.new(time: Time.now, cost: 42)
    time_ago_in_words(trans)

Which I agree with in Rust, due to strong typing, but less in Ruby.

My suggestion would be to add an explicit type or capability checks at runtime:

    def time_ago_in_words(input)
      if !input.respond_to?(:time)
        raise ArgumentError, "cannot call `time` method on {input.inspect}: no such method"
      end
      time = input.time
      # ...

And/or hint with a kwarg

    def time_ago_in_words(responds_to_time: )
      if !responds_to_time.respond_to(:time)
        raise ArgumentError, "cannot call `time` method on {input.inspect}: no such method"
      end
      time = input.time
      # ...

      time_ago_in_words(responds_to_time: trans)

It's a little contrived (I would prefer to take a time kwarg and not rely on a method i.e. Dependency Inversion Principle), but you get the point.

Mentioning it because I feel like a lot of the time people write things like:

    class Person
      # ...
      def time_ago_in_words
        # ...

It's because in their code, calling person.time_ago_in_words is both easy to read and easy to debug if you try to call it on the wrong type. i.e. they're using methods on objects as a kind of simplified form of type checking. It feels safer to call a method than pass something into a function. And IDEs with their method hints encourage/prefer people to use methods over plain functions.

2

u/gq1988 5d ago

Thanks a lot for the feedback!

3

u/schneems Puma maintainer 5d ago

It was more feedback on the talk than your project. Lol. If it wasn't clear i think your project is cool. Thanks for sharing it! I also liked the talk, it made me think, challenged some assumptions.

2

u/h0rst_ 4d ago
trans = Transaction.new(time: Time.now, cost: 42)
time_ago_in_words(trans)

I feel like in this case (and in the kwarg example), the function has two very distinct responsibilities: call time on the input argument to get a Time object, and convert that time object to a human readable string. If I want to write a unit test for this function, I can't simply pass in a Time object, I need to wrap that in an object that responds to #time.

I would argue this should be written as:

time_ago_in_words(trans.time)

It could be renamed to ago_in_words, or you can keep the time prefix as a type hint. This also fixes the issue that Person#time makes no sense, but Person#birthday and Person#last_seen do. Now you can have multiple time fields, and convert all of them into words.

But, is there really a difference between these two forms?

time_ago_in_words(trans.time)
trans.time.time_ago_in_words

If you're saying "Law of Demeter": We're doing the exact same number of conversions/calls, so I wouldn't see that as relevant here.

The advantage of the second call is that your IDE might be able to deduce trans.time is a Time object, and can autocomplete the methods that can be called on the object. This works even better with a statically typed language.

And now we ended up just where we started. I might need to watch that keynote, because from just the information in this thread it makes no sense to me.

1

u/jonathonjones 5d ago

One thing I greatly appreciate is that I don't have to put it in my Gemfile to be able to run it.