Короткий і ретельний посібник із нуля: що це таке, і як ним користуватися

Що означає null? Як nullреалізується? Коли слід використовувати nullу своєму вихідному коді, а коли - не використовувати?

Вступ

nullє основним поняттям у багатьох мовах програмування. Це повсюдно у всіх видах вихідних кодів, написаних цими мовами. Тому важливо повністю зрозуміти ідею null. Ми повинні розуміти його семантику та реалізацію, і ми повинні знати, як використовувати nullу своєму вихідному коді.

Коментарі на форумах програмістів іноді виявляють трохи плутанини з null. Деякі програмісти навіть намагаються повністю уникати null. Тому що вони вважають це "помилкою на мільйони доларів" - терміном, вигаданим Тоні Хоаром, винахідником null.

Ось простий приклад: припустимо, що Аліса email_addressвказує на null. Що це означає? Це означає, що Аліса не має електронної адреси? Або що її електронна адреса невідома? Або що це секретно? Або це просто означає, що email_addressє "невизначеним" або "неініціалізованим"? Подивимось. Прочитавши цю статтю, кожен повинен мати можливість без вагань відповісти на такі запитання.

Примітка: Ця стаття є нейтральною до мови програмування - наскільки це можливо. Пояснення загальні та не пов’язані з певною мовою. Будь ласка, зверніться до посібників з мов програмування, щоб отримати конкретні поради щодо null. Однак ця стаття містить кілька простих прикладів вихідного коду, показаних на Java. Але перекласти їх улюбленою мовою не складно.

Впровадження часу виконання

Перш ніж обговорювати значення null, нам слід зрозуміти, як nullреалізовано в пам'яті під час виконання.

Примітка: Ми розглянемо типову реалізацію null. Фактична реалізація в даному середовищі залежить від мови програмування та цільового середовища і може відрізнятися від реалізації, показаної тут.

Припустимо, ми маємо таку інструкцію вихідного коду:

String name = "Bob";

Тут ми оголошуємо змінну типу Stringта з ідентифікатором, nameякий вказує на рядок "Bob".

Сказати “вказує на” важливо в цьому контексті, оскільки ми припускаємо, що ми працюємо з посилальними типами (а не з типами значень ). Про це далі.

Щоб все було простіше, ми зробимо такі припущення:

  • Вищевказана інструкція виконується на 16-бітному процесорі з 16-бітовим адресним простором.
  • Рядки кодуються як UTF-16. Вони закінчуються знаком 0 (як у C або C ++).

На наступному малюнку показано фрагмент пам’яті після виконання вищевказаної інструкції:

Адреси пам'яті на наведеному малюнку вибрані довільно і не мають значення для нашого обговорення.

Як бачимо, рядок "Bob"зберігається за адресою B000 і займає 4 комірки пам'яті.

Змінна nameзнаходиться за адресою A0A1. Вмістом A0A1 є B000, що є початковим місцем пам'яті рядка "Bob". Ось чому ми говоримо: Змінна nameвказує на"Bob" .

Все йде нормально.

Тепер припустимо, що після виконання вищевказаної інструкції ви виконаєте наступне:

name = null;

Тепер nameвказує на null.

І це новий стан у пам’яті:

Ми бачимо, що для рядка, "Bob"який все ще зберігається в пам'яті , нічого не змінилося .

Примітка: Пам'ять, необхідна для зберігання рядка, "Bob"може пізніше звільнитися, якщо є збирач сміття і немає інших посилань "Bob", але це не має значення в нашому обговоренні.

Важливо те, що вміст A0A1 (що представляє значення змінної name) зараз становить 0000. Отже, змінна nameбільше не вказує на "Bob". Значення 0 (усі біти в нулі) є типовим значенням, яке використовується в пам'яті для позначення null. Це означає, що з цим не пов'язане значенняname . Ви також можете сприймати це як відсутність даних або просто відсутність даних .

Примітка: Фактичне значення пам'яті, яке використовується для позначення, nullзалежить від реалізації. Наприклад, специфікація віртуальної машини Java зазначена в кінці розділу 2.4. “ Довідкові типи та значення:”

Специфікація віртуальної машини Java не вимагає конкретного кодування значення null.

Запам’ятайте:

Якщо посилання вказує на null, це просто означає, що воно єжодне значення з цим не пов’язане .

Технічно кажучи, місце пам’яті, призначене посиланню, містить значення 0 (усі біти дорівнюють нулю) або будь-яке інше значення, яке позначає nullв даному середовищі.

Продуктивність

Як ми дізналися в попередньому розділі, операції, що включають nullнадзвичайно швидкі та прості у виконанні під час виконання.

Існує лише два види операцій:

  • Ініціалізуйте або встановіть посилання на null(наприклад name = null): Єдине, що потрібно зробити, це змінити вміст однієї комірки пам'яті (наприклад, встановивши її на 0).
  • Перевірте, чи посилання вказує на null(наприклад if name == null): Єдине, що потрібно зробити, це перевірити, чи містить комірка пам'яті посилання значення 0.

Запам’ятайте:

Операції на nullнадзвичайно швидких і дешевих.

Посилання проти типів значень

Поки що ми передбачали роботу з еталонними типами . Причина цього проста: nullдля типів значень не існує .

Чому?

Як ми вже бачили раніше, посилання - це вказівник на адресу пам'яті, яка зберігає значення (наприклад, рядок, дату, клієнта тощо). Якщо посилання вказує на null, тоді жодне значення з ним не пов’язане.

З іншого боку, значення - це, за визначенням, саме значення. Тут немає вказівника. Тип значення зберігається як саме значення. Тому концепція nullне існує для типів значень.

Наступна картинка демонструє різницю. З лівого боку ви знову можете побачити пам'ять, якщо змінна nameє посиланням, що вказує на "Боб". Права сторона показує пам'ять у випадку, коли змінна nameє типом значення.

Як бачимо, у випадку типу значення саме значення безпосередньо зберігається за адресою A0A1, яка пов'язана зі змінною name.

Можна було б сказати набагато більше про посилання проти типів значень, але це виходить за рамки цієї статті. Зауважте також, що деякі мови програмування підтримують лише довідкові типи, інші підтримують лише типи значень, а деякі (наприклад, C # та Java) підтримують обидва.

Запам’ятайте:

Концепція nullіснує лише для еталонних типів. Він не існує для типів значень .

Meaning

Suppose we have a type person with a field emailAddress. Suppose also that, for a given person which we will call Alice, emailAddress points to null.

What does this mean? Does it mean that Alice doesn’t have an email address? Not necessarily.

As we have seen already, what we can assert is that no value is associated with emailAddress.

But why is there no value? What is the reason of emailAddress pointing to null? If we don't know the context and history, then we can only speculate. The reason for nullcould be:

Alice doesn’t have an email address. Or…

Alice has an email address, but:

  • it has not yet been entered in the database
  • it is secret (unrevealed for security reasons)
  • there is a bug in a routine that creates a person object without setting field emailAddress
  • and so on.

In practice we often know the application and context. We intuitively associate a precise meaning to null. In a simple and flawless world, null would simply mean that Alice actually doesn't have an email address.

When we write code, the reason why a reference points to null is often irrelevant. We just check for null and take appropriate actions. For example, suppose that we have to write a loop that sends emails for a list of persons. The code (in Java) could look like this:

for ( Person person: persons ) { if ( person.getEmailAddress() != null ) { // code to send email } else { logger.warning("No email address for " + person.getName()); }}

In the above loop we don’t care about the reason for null. We just acknowledge the fact that there is no email address, log a warning, and continue.

Remember:

If a reference points to null then it always means that there isno value associated with it.

In most cases, null has a more specific meaning that depends on the context.

Why is it null?

Sometimes it is important to know why a reference points to null.

Consider the following function signature in a medical application:

List getAllergiesOfPatient ( String patientId )

In this case, returning null (or an empty list) is ambiguous. Does it mean that the patient doesn't have allergies, or does it mean that an allergy test has not yet been performed? These are two semantically very different cases that must be handled differently. Or else the outcome might be life-threatening.

Just suppose that the patient has allergies, but an allergy test has not yet been done and the software tells the doctor that 'there are no allergies'. Hence we need additional information. We need to know why the function returns null.

It would be tempting to say: Well, to differentiate, we return null if an allergy test has not yet been performed, and we return an empty list if there are no allergies.

DON’T DO THIS!

This is bad data design for multiple reasons.

The different semantics for returning null versus returning an empty list would need to be well documented. And as we all know, comments can be wrong (i.e. inconsistent with the code), outdated, or they might even be inaccessible.

There is no protection for misuses in client code that calls the function. For example, the following code is wrong, but it compiles without errors. Moreover, the error is difficult to spot for a human reader. We can’t see the error by just looking at the code without considering the comment of getAllergiesOfPatient:

List allergies = getAllergiesOfPatient ( "123" ); if ( allergies == null ) { System.out.println ( "No allergies" ); // <-- WRONG!} else if ( allergies.isEmpty() ) { System.out.println ( "Test not done yet" ); // <-- WRONG!} else { System.out.println ( "There are allergies" );}

The following code would be wrong too:

List allergies = getAllergiesOfPatient ( "123" );if ( allergies == null || allergies.isEmpty() ) { System.out.println ( "No allergies" ); // <-- WRONG!} else { System.out.println ( "There are allergies" );}

If the null/empty-logic of getAllergiesOfPatient changes in the future, then the comment needs to be updated, as well as all client code. And there is no protection against forgetting any one of these changes.

If, later on, there is another case to be distinguished (e.g. an allergy test is pending — the results are not yet available), or if we want to add specific data for each case, then we are stuck.

So the function needs to return more information than just a list.

There are different ways to do this, depending on the programming language we use. Let’s have a look at a possible solution in Java.

In order to differentiate the cases, we define a parent type AllergyTestResult, as well as three sub-types that represent the three cases (NotDone, Pending, and Done):

interface AllergyTestResult {}
interface NotDoneAllergyTestResult extends AllergyTestResult {}
interface PendingAllergyTestResult extends AllergyTestResult { public Date getDateStarted();}
interface DoneAllergyTestResult extends AllergyTestResult { public Date getDateDone(); public List getAllergies(); // null if no allergies // non-empty if there are // allergies}

As we can see, for each case we can have specific data associated with it.

Instead of simply returning a list, getAllergiesOfPatient now returns an AllergyTestResult object:

AllergyTestResult getAllergiesOfPatient ( String patientId )

Client code is now less error-prone and looks like this:

AllergyTestResult allergyTestResult = getAllergiesOfPatient("123");
if (allergyTestResult instanceof NotDoneAllergyTestResult) { System.out.println ( "Test not done yet" ); } else if (allergyTestResult instanceof PendingAllergyTestResult) { System.out.println ( "Test pending" ); } else if (allergyTestResult instanceof DoneAllergyTestResult) { List list = ((DoneAllergyTestResult) allergyTestResult).getAllergies(); if (list == null) { System.out.println ( "No allergies" ); } else if (list.isEmpty()) { assert false; } else { System.out.println ( "There are allergies" ); }} else { assert false;}

Note: If you think that the above code is quite verbose and a bit hard to write, then you are not alone. Some modern languages allow us to write conceptually similar code much more succinctly. And null-safe languages distinguish between nullable and non-nullable values in a reliable way at compile-time — there is no need to comment the nullability of a reference or to check whether a reference declared to be non-null has accidentally been set to null.

Remember:

If we need to know why there is no value associated with a reference, then additional data must be provided to differentiate the possible cases.

Initialization

Consider the following instructions:

String s1 = "foo";String s2 = null;String s3;

The first instruction declares a String variable s1 and assigns it the value "foo".

The second instruction assigns null to s2.

The more interesting instruction is the last one. No value is explicitly assigned to s3. Hence, it is reasonable to ask: What is the state of s3 after its declaration? What will happen if we write s3 to the OS output device?

It turns out that the state of a variable (or class field) declared without assigning a value depends on the programming language. Moreover, each programming language might have specific rules for different cases. For example, different rules apply for reference types and value types, static and non-static members of a class, global and local variables, and so on.

As far as I know, the following rules are typical variations encountered:

  • It is illegal to declare a variable without also assigning a value
  • There is an arbitrary value stored in s3, depending on the memory content at the time of execution - there is no default value
  • A default value is automatically assigned to s3. In case of a reference type, the default value is null. In case of a value type, the default value depends on the variable’s type. For example 0 for integer numbers, false for a boolean, and so on.
  • the state of s3 is 'undefined'
  • the state of s3 is 'uninitialized', and any attempt to use s3 results in a compile-time error.

The best option is the last one. All other options are error-prone and/or impractical — for reasons we will not discuss here, because this article focuses on null.

As an example, Java applies the last option for local variables. Hence, the following code results in a compile-time error at the second line:

String s3;System.out.println ( s3 );

Compiler output:

error: variable s3 might not have been initialized

Remember:

If a variable is declared, but no explicit value is assigned to it, then it’s state depends on several factors which are different in different programming languages.

In some languages, null is the default value for reference types.

When to Use null (And When Not to Use It)

The basic rule is simple: null should only be allowed when it makes sense for an object reference to have 'no value associated with it'. (Note: an object reference can be a variable, constant, property (class field), input/output argument, and so on.)

For example, suppose type person with fields name and dateOfFirstMarriage:

interface Person { public String getName(); public Date getDateOfFirstMarriage();}

Every person has a name. Hence it doesn’t make sense for field name to have 'no value associated with it'. Field name is non-nullable. It is illegal to assign null to it.

On the other hand, field dateOfFirstMarriage doesn't represent a required value. Not everyone is married. Hence it makes sense for dateOfFirstMarriage to have 'no value associated with it'. Therefore dateOfFirstMarriage is a nullable field. If a person's dateOfFirstMarriage field points to null then it simply means that this person has never been married.

Note: Unfortunately most popular programming languages don’t distinguish between nullable and non-nullable types. There is no way to reliably state that null can never be assigned to a given object reference. In some languages it is possible to use annotations, such as the non-standard annotations @Nullable and @NonNullable in Java. Here is an example:

interface Person { public @Nonnull String getName(); public @Nullable Date getDateOfFirstMarriage();}

However, such annotations are not used by the compiler to ensure null-safety. Still, they are useful for the human reader, and they can be used by IDEs and tools such as static code analyzers.

It is important to note that null should not be used to denote error conditions.

Consider a function that reads configuration data from a file. If the file doesn’t exist or is empty, then a default configuration should be returned. Here is the function’s signature:

public Config readConfigFromFile ( File file )

What should happen in case of a file read error?

Simply return null?

NO!

Each language has it’s own standard way to signal error conditions and provide data about the error, such as a description, type, stack trace, and so on. Many languages (C#, Java, etc.) use an exception mechanism, and exceptions should be used in these languages to signal run-time errors. readConfigFromFile should not return null to denote an error. Instead, the function's signature should be changed in order to make it clear that the function might fail:

public Config readConfigFromFile ( File file ) throws IOException

Remember:

Allow null only if it makes sense for an object reference to have 'no value associated with it'.

Don’t use null to signal error conditions.

Null-safety

Consider the following code:

String name = null;int l = name.length();

Під час виконання вищевказаний код призводить до сумнозвісної помилки нульового покажчика , оскільки ми намагаємось виконати метод посилання, який вказує на null. Наприклад, у C # NullReferenceExceptionвикидається a , у Java - a NullPointerException.

Помилка нульового вказівника неприємна.

Це найчастіша помилка у багатьох програмних додатках, і вона стала причиною незліченних проблем в історії розробки програмного забезпечення. Тоні Хоаре, винахідник null, називає це "помилкою на мільярд доларів".

Але Тоні Хоаре (лауреат премії Тьюрінга в 1980 році і винахідник алгоритму Quicksort) також дає підказку щодо рішення у своїй промові:

Пізніші мови програмування… запровадили декларації для ненульових посилань. Це рішення, яке я відкинув у 1965 році.

Contrary to some common belief, the culprit is not null per se. The problem is the lack of support for null handling in many programming languages. For example, at the time of writing (May 2018), none of the top ten languages in the Tiobe index natively differentiates between nullable and non-nullable types.

Therefore, some new languages provide compile-time null-safety and specific syntax for conveniently handling null in source code. In these languages, the above code would result in a compile-time error. Software quality and reliability increases considerably, because the null pointer error delightfully disappears.

Null-safety is a fascinating topic that deserves its own article.

Remember:

Whenever possible, use a language that supports compile-time null-safety.

Note: Some programming languages (mostly functional programming languages like Haskell) don’t support the concept of null. Instead, they use the Maybe/Optional Patternto represent the ‘absence of a value’. The compiler ensures that the ‘no value’ case is handled explicitly. Hence, null pointer errors cannot occur.

Summary

Here is a summary of key points to remember:

  • If a reference points to null, it always means that there is no value associated with it.
  • In most cases, null has a more specific meaning that depends on the context.
  • If we need to know why there is no value associated with a reference, then additional data must be provided to differentiate the possible cases.
  • Allow null only if it makes sense for an object reference to have 'no value associated with it'.
  • Don’t use null to signal error conditions.
  • The concept of null exists only for reference types. It doesn't exist for value types.
  • In some languages null is the default value for reference types.
  • null operations are exceedingly fast and cheap.
  • Whenever possible, use a language that supports compile-time-null-safety.