23

When I add a .equals() method to a class in Apex, it seems that I'm hiding and not overriding the version in Object. This is evidenced by the fact that I don't have to use the override keyword in my class definition. So, I can write:

public virtual class MyClass {
    public Boolean equals(Object other) {
        System.debug('Called my equals');
        return false;
    }
}

And then it doesn't behave like a virtual function. If I call .equals() on a reference to MyClass, it calls my version of it. If I call it on an Object reference to an instance of MyClass, it calls the Object version of it i.e.

MyClass m = new MyClass();
Object o = new MyClass();

m.equals('a'); // Prints 'Called my equals'
o.equals('a'); // Prints nothing

This has real and confusing consequences. For example, when we use get(field) on an SObject to get values that happen to be String, we might want to use .equals() to do a case-sensitive comparison. But, since get(field) is typed as returning an Object, the comparison gets done in a case-insensitive way. So, the following fails

System.assert(
        !new Account(Name = 'a')
        .get(Account.Name)
        .equals(new Account(Name = 'A').get(Account.Name)),
        'They are different cases!'
        );

And it can be fixed with a cast!

System.assert(
        !((String)new Account(Name = 'a')
        .get(Account.Name))
        .equals(new Account(Name = 'A').get(Account.Name)),
        'They are different cases!'
        );

What's going on?

2 Answers 2

9

The Object "class" isn't a real class, which causes all kinds of problems. This was obviously the first object that was ever written, as all objects derive from it implicitly. It's implemented by the ANY object internally, and this code is frustratingly broken.

For example, String.toString() works fine most of the time, but if you use String.join() on a list of objects, you get the default String.toString() implementation, even if you've overridden it with a custom definition.

public class Demo {
    public override String toString() {
        return 'hello world';
    }
}
System.debug(
    String.join(new Object[] { new Demo() },'')
); // Outputs anon$Demo in Execute Anonymous

The same is true for equals() and hashCode(). There are times when you'll get the expected output, and times when you won't, as above.

There's not much we can do about this. ANY is fundamentally broken in a few different ways, notably its ability to cause random Internal Server Errors in some edge cases, as well as overrides being completely ignored by some system functions.

You'll find all kinds of edge cases in Apex where this happens, and it bleeds over to things like Visualforce and Lightning from time to time. We don't know "why" this happens, or if it will ever be fixed, but the best advice I have is to only use these overrides if absolutely necessary, and only in documented ways.

For example:

public class Demo {
    public Boolean equals(Object o) {
        return false;
    }
}
Demo x = new Demo();
System.debug(
    x == x
);

Currently returns... an Internal Server Error, at least in my org. This is because, again, it's not a "real" object, so trying to monkey with the internals causes problems. You can only use equals() for its one intended use case, which is for inclusion in a Map key or Set.

Anything else outside of the documentation is not supported, and may break in amusing, hard-to-fix ways. Hopefully one day, salesforce.com will get around to fixing Object, but until then, try to carefully follow the documentation.

If you don't follow documented procedures, you will run into many of the weird edge cases that exist in Apex.

3
  • 2
    Interesting answer. That second example works correctly for me
    – Kasper
    Commented Mar 30, 2021 at 17:45
  • Thanks for the answer... That was the kind of information I was hoping for. I disagree that maps and sets are the only allowed case for .equals(). String itself overrides .equals() to make a case-sensitive comparison. And that's what brought me to this. I was getting strings using sObject.get(field) and wanting to compare them in a case-sensitive way. Ultimately, it looks like I'll have to use typeof and downcasting :(
    – Aidan
    Commented Mar 31, 2021 at 8:23
  • @Aidan String.valueOf(x).equals(String.valueOf(y)) should be a safe alternative for all types while getting you case sensitivity. While String.equals is technically an override, the fact that it doesn't work if you use ANY is a signal that things are still pretty much as stated here. Still, I'd forgotten about String.equals when I wrote this, I don't usually need case sensitive comparisons.
    – sfdcfox
    Commented Mar 31, 2021 at 12:33
8

I love that is a 'why' question, which basically calls for speculation, so I'll just go with it.

Working on Clayton we have encountered a number of oddities in the Apex language (i.e. parsing, type system, inheritance, etc) that certainly raise the occasional "why?" question.

In my opinion, Apex lacks the rigour of formal languages. Still, it's pretty impressive with the engineers at Salesforce achieved; for being something that started as a bunch of classes in a Java monolith, they got a lot of things right from the start.

My speculation is that, at some point, a long time ago, an engineer just needed to get a ticket out of the door, raised the issue with their team, collectively figured out that handling things properly would take months of work, and that most people wouldn’t notice after all.

That’s how these types of thing usually happen in my experience.

Regardless, nice catch and super entertaining question.

Yeah, maybe it's just a bug, but I'll stay tuned hoping for a great engineering anecdote coming out of this.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .