3

It looks like duplicate sObjects can coexist in the same set, while duplicate class instances cannot. Is this a bug or the intended behavior?

This post and this post discuss a similar issue, but the accepted answers seem to indicate that this is a result of the field values being different for each "duplicate" set member, having been changed after being retrieved in separate queries or separately instantiated.

This doesn't seem to apply to my case because the duplicates added to the set all reference the same instance, as confirmed by the tests below.

Test with sObject:

Contact original = new Contact();
original.FirstName = 'original';
Set<Contact> someSet = new Set<Contact>();

someSet.add(original);

for(integer i = 0; i < 2; i++){    
    original.FirstName = 'changed ' + String.valueOf(i);
    for(Contact c : someSet){
        System.assertEquals(original,c);
    }
    someSet.add(original);   
}

System.debug('set size: ' + someSet.size());
for(Contact c : someSet){
    System.debug(c);
}

Integer q = 1;
for(Contact c : someSet){
    c.FirstName = 'changed again ' + String.valueOf(q);
    q++;
    System.debug(original.FirstName); 
}

/* 
debug: 

set size: 3
Contact:{FirstName=changed 1}
Contact:{FirstName=changed 1}
Contact:{FirstName=changed 1}
changed again 1
changed again 2
changed again 3
*/

Test with Apex class:

public class SomeClass {    
    public String s;
}

SomeClass original = new SomeClass();
original.s = 'original';
Set<SomeClass> someSet = new Set<SomeClass>();

someSet.add(original);

for(integer i = 0; i < 2; i++){    
    original.s = 'changed ' + String.valueOf(i);
    for(SomeClass c : someSet){
        System.assertEquals(original,c); 
    }
    someSet.add(original);   
}

System.debug('set size: ' + someSet.size());
for(SomeClass c : someSet){
    System.debug(c);
}

Integer q = 1;
for(SomeClass c : someSet){
    c.s = 'changed again ' + String.valueOf(q);
    q++;
    System.debug(original.s); 
}

/* 
debug log: 

set size: 1
SomeClass:[s=changed 3]
changed again 1
*/
4

It works "correctly" in the SObject version, because SObject properly implements hashCode and equals. It doesn't work correctly in your custom object example, because you failed to implement hashCode and equals. They would otherwise have the same behavior.

Generally speaking, once you insert an instance of something into the set, modifying the value afterwards results in the Set getting corrupted. If you debug the entire set, it will fix the problem until you modify the internal state of any element again.

Do not modify an instance of a object in a set, because if the hashCode changes, the object will be match multiple hash codes. This a limitation of the internal implementation of sets.

The "size" method returns the number of hashCodes stored, not the number of values actually in the set.

To explain, when you call add, hashCode is called, and then the internal state is checked to see if an existing hashCode is already stored. If so, equals is called on each element to see if it's really a duplicate or not. If hashCode is the same and equals returns true for at least one element, then the element is not added again.

By modifying the internal state, each time you call add, a different hashCode is generated, and the same instance gets stored more than once. This has odd behavior in the implementation. To see what I'm talking about, let's add some more debug info.


Contact original = new Contact();
original.FirstName = 'original';
Set<Contact> someSet = new Set<Contact>();
System.debug(System.hashCode(original));

someSet.add(original);

for(integer i = 0; i < 2; i++){    
    original.FirstName = 'changed ' + String.valueOf(i);
    System.debug(System.hashCode(original));
    for(Contact c : someSet){
        System.assertEquals(original,c);
    }
    someSet.add(original);   
}

System.debug('set size: ' + someSet.size());

The debug logs look like this now:

19:48:23.1 (3137192)|USER_DEBUG|[4]|DEBUG|762282218
19:48:23.1 (3287977)|USER_DEBUG|[10]|DEBUG|702578943
19:48:23.1 (3795063)|USER_DEBUG|[10]|DEBUG|702578942
19:48:23.1 (3894387)|USER_DEBUG|[17]|DEBUG|set size: 3

As you can see, there are three unique hash code values stored in the set. This is the expected behavior. This is also the reason why you can't modify the hash code (typically the object's internal state), otherwise this will happen.

Debugging the set causes the internal state to be fixed:

System.debug(someSet);
System.debug('set size: ' + someSet.size());

18:50:27:002 USER_DEBUG [19]|DEBUG|set size: 1

However, as long as the hashCode doesn't change, you can fix the behavior despite internal modifications. This works best if you have a large range of hash code values assigned to each unique instance:

public class SomeClass {    
    static Integer uniqueCode = 0;

    public SomeClass() {
        myCode = uniqueCode++;
    }
    public Integer hashCode() {
        return myCode;
    }
    public Boolean equals(Object o) {
        return s == ((SomeClass)o).s;
    }
    public Integer myCode;
    public String s;
}

Output:

20:01:07.1 (4450127)|USER_DEBUG|[14]|DEBUG|set size: 1
20:01:07.1 (4588791)|USER_DEBUG|[16]|DEBUG|SomeClass:[myCode=0, s=changed 1, uniqueCode=1]

Note: I had to move SomeClass to a real class, because Execute Anonymous kept crashing, but this should demonstrate the principle.

2
  • Thanks for the thorough explanation! Just to make sure I (roughly) understand, you’re saying that while in this case each set member is a pointer to the same object, sets are implemented such that they store the hash code of the pointer’s value at the time the member was added to the set, and don’t automatically re-hash if the pointer’s value later changes?
    – Sequoyah
    May 3 '17 at 1:41
  • 1
    @Sequoyah Yes, that's basically right. The hash code is only calculated upon entry (using add or addAll).
    – sfdcfox
    May 3 '17 at 1:45

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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