The Secret Life of an SObject: Equality, Sets and Maps

Equality

When testing for equality between sObjects in Apex, it is the sObjects’ properties – the values of the sObjects’ fields – which are compared. So two separate sObject instances that have the same field values are considered equal:

Account foo1 = new Account(), foo2 = new Account();
foo1.Name = 'bar';
foo1.AnnualRevenue = 12345;
foo2.Name = 'bar';
foo2.AnnualRevenue = 12345;
system.assert(foo1 == foo2);

But, if we were to mimic our sObject using a custom Apex type, we would find that by default, two instances with the same property values would not be considered equal:

MyAccount foo1 = new MyAccount(), foo2 = new MyAccount();
foo1.Name = 'bar';
foo1.AnnualRevenue = 12345;
foo2.Name = 'bar';
foo2.AnnualRevenue = 12345;
system.assert(foo1 != foo2);

public class MyAccount
{
   public String Name;
   public Decimal AnnualRevenue;
}

See Method Declarations in Anonymous Apex

This is because with Apex classes we have to decide how we want equality to be evaluated, by defining our own equals method.

If we don’t define an equals method then the equality operator will compare object references (memory locations) – in other words asking “are these references to the exact same instance?”

If we wish, we can make the same reference comparison with sObjects too, using the exact equality and exact inequality operators.

Account foo1 = new Account(), foo2 = new Account();
foo1.Name = 'bar';
foo1.AnnualRevenue = 12345;
foo2.Name = 'bar';
foo2.AnnualRevenue = 12345;
system.assert(foo1 !== foo2);
foo2 = foo1;
system.assert(foo1 === foo2);

We cannot however define our own equals method for sObjects.

Sets

When working with Sets and Maps in Apex, we also need to consider how sObjects are evaluated for uniqueness within these collection types.

A Set is a collection which does not allow duplicate values, and the behaviour is that when attempting to add a duplicate, it is the value added first that is retained in the Set.

For a custom Apex type we can define equals and hashCode methods to determine uniqueness of instances. However, if we don’t define equals and hashCode methods, the default behaviour for a custom Apex type appears to be to use object references (memory locations).

This means that distinct instances of the same class with identical member values will be considered unique in a Set:

MyAccount foo1 = new MyAccount(), foo2 = new MyAccount();
foo1.Name = 'bar';
foo2.Name = 'bar';
Set<MyAccount> accountSet = new Set<MyAccount>();
accountSet.add(foo1);
accountSet.add(foo2);
system.assertEquals(2, accountSet.size());
system.assert(accountSet.contains(foo1));
system.assert(accountSet.contains(foo2));

public class MyAccount
{
   public String Name;
}

SObject behaviour is different to this. With sObjects there’s no opportunity to define our own equals and hashCode methods; uniqueness (as equality) is determined by an sObject’s field values:

Account foo1 = new Account(), foo2 = new Account();
foo1.Name = 'bar';
foo2.Name = 'bar';
Set<Account> accountSet = new Set<Account>();
accountSet.add(foo1);
accountSet.add(foo2);
system.assertEquals(1, accountSet.size());
system.assert(accountSet.contains(foo1));
system.assert(accountSet.contains(foo2));
Account item = new List<Account>(accountSet)[0];
system.assert(item===foo1);
system.assert(item!==foo2);

The Set contains only one item, though the “contains” method returns true for both items. By retrieving the item from the Set we find that it is exactly equal to the first item added: foo1.

Maps

A map is a collection of key-value pairs where each unique key maps to a single value“. When we add a new key-value pair to a Map, if the key exists in the Map then the new value will replace the existing value.

In the following example we are using sObjects as keys in a Map, and although the sObject instances are distinct, they are considered duplicate keys:

Account foo1 = new Account(), foo2 = new Account();
foo1.Name = 'bar';
foo2.Name = 'bar';
Map<Account,String> myAccountMap = new Map<Account,String>();
myAccountMap.put(foo1,'this is foo1');
myAccountMap.put(foo2,'this is foo2');
system.assertEquals(1,myAccountMap.size());
system.assertEquals('this is foo2',myAccountMap.get(foo1));
system.assertEquals('this is foo2',myAccountMap.get(foo2));
Account key = new List<Account>(myAccountMap.keySet())[0];
system.assert(key===foo1);
system.assert(key!==foo2);

We have added two items to the Map, though on inspection we find it contains only one – the second value has replaced the first.

We also find that we can use either foo1 or foo2 as the key to retrieve the value, and the value retrieved is the value inserted with foo2.

Finally when we extract the key from the Map’s keySet we find it is exactly equal to the first SObject foo1, but not to foo2. This is consistent with the Set behaviour we saw above.

So, when adding the second key-value pair, the value was replaced in the Map, but the key was not – the Map contains key 1 and value 2.

Don’t do this at home… seriously, don’t do it…

The equality and “collection uniqueness” of an sObject is derived from the values of its fields. However, what if an sObject’s field values are modified after it has been used as a key to put a value in a Map?

Account foo1 = new Account(), foo2 = new Account();
foo1.Name = 'bar';
foo2.Name = 'bar';
Map<Account,String> myAccountMap = new Map<Account,String>();
myAccountMap.put(foo1,'this is foo1');
myAccountMap.put(foo2,'this is foo2');
system.assertEquals('this is foo2',myAccountMap.get(foo1));
system.assertEquals('this is foo2',myAccountMap.get(foo2));
foo1.Name = 'force';
system.assertEquals(null,myAccountMap.get(foo1));
system.assertEquals(null,myAccountMap.get(foo2));
system.assertEquals('this is foo2',myAccountMap.values()[0]);
Account key = new List<Account>(myAccountMap.keySet())[0];
system.assert(key===foo1);

As before, we add two values to a Map using distinct sObject keys foo1 and foo2 (which are considered equal / duplicates), after which we can use either sObject as the key to retrieve the foo2 value.

Next we change a field value on foo1.

Now we can see from the “values” method that the value apparently still exists in the Map, and we can also confirm that foo1 is present in the keySet, but we can no longer reach the value using either key.

The Map is broken, but if we had changed a field on foo2 and not foo1, the Map data would be intact.

Using sObjects as keys to Maps should generally be avoided.

Edit 24 Sep 2015: sfdcfox suggests a use case for sObject as a map key on this Salesforce Stack Exchange post.

Links
Force.com Apex Code Developer’s Guide – Using Custom Types in Map Keys and Sets
Force.com Apex Code Developer’s Guide – Understanding Expression Operators
Force.com Apex Code Developer’s Guide – Collections
Salesforce StackExchange – How to implement hashCode cleanly?
Force.com Discussion Boards

This entry was posted in Documentation and tagged , , . Bookmark the permalink.

6 Responses to The Secret Life of an SObject: Equality, Sets and Maps

  1. jessealtman says:

    This was an interesting read. I didn’t realize that SObjects and Apex objects (like wrapper classes) have different implementations of the equals method. That is the type of thing that will drive you crazy late at night working on a bug, asking yourself “why won’t this work like it is supposed to work?”

    Like

  2. Force 201 says:

    Stephen,

    Good post, but your statement “An object’s uniqueness in a Set is determined by its hash code” is wrong. The purpose of the hash code is generally to coarsely group the items into a “bucket” with the equals method then used to find the matching item amongst the small list of items in the bucket. Hence the contract “If two objects are equal, then they must have the same hash code” and “If two objects have the same hashcode, they may or may not be equal”.

    Keith

    Liked by 1 person

  3. Pingback: Working with Apex Mocks Matchers and Unit Of Work | Andy in the Cloud

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s