Strange Behavior with null sObject in Apex Class

It's a feature (mostly). The most general use is to allow you to bypass checking for null pointers from query results. For example, consider the following code:

for(Contact record:[SELECT Account.Name FROM Contact WHERE AccountId = NULL]) {
    System.debug(record.Account.Name);
}

You'll get a bunch of null values in your debug logs (if you have any contacts without accounts), even though Account itself is also null.

Note that this doesn't work with method calls; once you try to call a method, you better have a non-null object to work with. For that reason, the following code won't work:

for(Contact record:[SELECT Account.Name FROM Contact WHERE AccountId = NULL]) {
    System.debug(record.getSObject('Account').get('Name'));
}

As far as I know, this isn't documented anywhere[citation needed], but it's always worked this way.

This is in the Accessing sObject Fields Through Relationships documentation:

The expression c.Account.Name, and any other expression that traverses a relationship, displays slightly different characteristics when it is read as a value than when it is modified:

When being read as a value, if c.Account is null, then c.Account.Name evaluates to null, but does not yield a NullPointerException.

This design allows developers to navigate multiple relationships without the tedium of having to check for null values.

When being modified, if c.Account is null, then c.Account.Name does yield a NullPointerException

Usually, a developer runs in to this by accident, and ends up with the opposite assumption as the developer who first gets bitten by NullPointerException (e.g. starts to check for nulls on everything).

I suspect that the reason why the NullPointerException happens on "line 13" is actually a defect in the run-time environment[citation needed], since it actually behaves differently than other SObject fields and relationships. In general, you should always initialize a variable, because NullPointerException is rather easy to avoid if you always use non-null values (i.e. it allows you to write cleaner code than if you have to check for null everywhere).

Also, you may not have noticed, but the same is true for child relationships; they are never null, even if no records exist:

for(Account record:[SELECT (SELECT Id FROM ActivityHistories) FROM Account WHERE LastActivityDate = NULL]) {
    System.debug(record.ActivityHistories.size());
}

You'll get a whole bunch of 0 values, instead of NullPointerException errors. Again, the intent is to allow us to free up some of our usual null checks and focus on cleaner code.

You should take advantage of this behavior to reduce code complexity, but always remember that it only extends to fields and relationships on SObject variables, and does not extend to any other standard class or method, or to any user-defined classes (e.g. the code that we developers write).


Thinking about this more, I think what we're observing here is also a result of Namespace, Class, and Variable Name Precedence, which I'll quote the relevant parts here:

The parser first assumes that name1 is a local variable with name2 - nameN as field references.

Followed by:

However, with class variables Apex also uses dot notation to reference member variables. Those member variables might refer to other class instances, or they might refer to an sObject which has its own dot notation rules to refer to field names (possibly navigating foreign keys).

Once you enter an sObject field in the expression, the remainder of the expression stays within the sObject domain, that is, sObject fields cannot refer back to Apex expressions.

So, by way of theory:

The system evaluates tc as a local variable, and further names as field references (i.e. you enter the "sObject domain"), and from there, it continues to evaluate the remainder as if tc were originally an sObject itself. This feels like a bug to me, especially since it only works "some of the time." You might want to read more about how the parser works for more information.

... Now I think I need to ask a PM if that's intentional, or if there's a Known Issue regarding the behavior.