Static class members. Do not let them ruin your code

Original author: David C. Zentgraf
  • Transfer
I have long wanted to write on this topic. The first impetus was Miško Hevery's article " Static Methods are Death to Testability ". I wrote a response article, but never published it. But recently I saw something that can be called "Class-Oriented Programming." This refreshed my interest in the topic and here is the result.

“Class-Oriented Programming” is when classes are used that consist only of static methods and properties, and an instance of the class is never created. In this article, I will say that:
  • this has no advantages over procedural programming
  • do not refuse objects
  • the presence of static class members! = death tests

Although this article is about PHP, the concepts apply to other languages.


Dependencies


Typically, the code depends on another code. For instance:

$foo = substr($bar, 42);

This code depends on the variable $barand function substr. $barIs just a local variable defined a little higher in the same file and in the same scope. substrIs a core feature of PHP. Everything is simple here.

Now, an example:

$foo = normalizer_normalize($bar);

normalizer_normalize is an Intl package feature that has been integrated with PHP since version 5.3 and can be installed separately for older versions. Here it’s a bit more complicated - the performance of the code depends on the availability of a particular package.

Now, this option:

class Foo {
   public static function bar() {
       return Database::fetchAll("SELECT * FROM `foo` WHERE `bar` = 'baz'");
   }
}

This is a typical example of class-oriented programming. Footightly tied to Database. And we also assume that the class Databasehas already been initialized and the connection to the database (DB) has already been established. Presumably using this code would be:

Database::connect('localhost', 'user', 'password');
$bar = Foo::bar();

Foo::barimplicitly depends on accessibility Databaseand its internal state. You cannot use Foowithout Database, but Database, presumably, requires a connection to the database. How can you be sure that the connection to the database is already established when the call occurs Database::fetchAll? One way looks like this:

class Database {
   protected static $connection;
   public static function connect() {
       if (!self::$connection) {
           $credentials = include 'config/database.php';
           self::$connection = some_database_adapter($credentials['host'], $credentials['user'], $credentials['password']);
       }
   }
   public static function fetchAll($query) {
       self::connect();
       // используем self::$connection...
       // here be dragons...
       return $data;
   }
}

When calling Database::fetchAll, we check the existence of the connection by calling the method connect, which, if necessary, gets the connection parameters from the config. This means that it Databasedepends on the file config/database.php. If this file does not exist, it cannot function. We are going further. The class is Databasebound to one database. If you need to transfer other connection parameters, then this will be at least not easy. The lump is growing. Foonot only depends on availability Database, but also depends on its condition. Databasedepends on the specific file in the specific folder. Those. implicitly classFoodepends on the file in the folder, although this is not visible by its code. Moreover, there are a lot of dependencies on the global state. Each piece depends on another piece, which should be in the right condition and nowhere is this clearly indicated.

Something familiar...


Does it seem like a procedural approach? Let's try rewriting this example in a procedural style:

function database_connect() {
   global $database_connection;
   if (!$database_connection) {
       $credentials = include 'config/database.php';
       $database_connection = some_database_adapter($credentials['host'], $credentials['user'], $credentials['password']);
   }
}
function database_fetch_all($query) {
   global $database_connection;
   database_connect();
   // используем $database_connection...
   // ...
   return $data;
}
function foo_bar() {
   return database_fetch_all("SELECT * FROM `foo` WHERE `bar` = 'baz'");
}

Find 10 differences ...
Hint: the only difference is visibility Database::$connectionand $database_connection.

In the class-oriented example, the connection is available only for the class itself Database, and in the procedural code this variable is global. The code has the same dependencies, communications, problems and works the same way. There is practically no difference between $database_connectionand Database::$connection- it's just a different syntax for the same thing, both variables have a global state. The easy touch of the namespace, thanks to the use of classes, is certainly better than nothing, but it doesn't change anything seriously.

Class-oriented programming is like buying a car in order to sit in it, periodically open and close doors, jump on the seats, accidentally making airbags deploy, but never turn the ignition key and never budge. This is a complete misunderstanding of the essence.

Turn the ignition key


Now, let's try OOP. Let's start with the implementation Foo:

class Foo {
   protected $database;
   public function __construct(Database $database) {
       $this->database = $database;
   }
   public function bar() {
       return $this->database->fetchAll("SELECT * FROM `foo` WHERE `bar` = 'baz'");
   }
}

Now it Foodoes not depend on the specific Database. When creating an instance Foo, you need to pass some object that has the characteristics Database. It can be both an instance Databaseand its descendant. So we can use another implementation Databasethat can receive data from somewhere else. Or has a caching layer. Or it is a stub for tests, and not a real database connection. Now we need to create an instance Database, this means that we can use several different connections to different databases, with different parameters. Let's implement Database:

class Database {
   protected $connection;
   public function __construct($host, $user, $password) {
       $this->connection = some_database_adapter($host, $user, $password);
       if (!$this->connection) {
           throw new Exception("Couldn't connect to database");
       }
   }
   public function fetchAll($query) {
       // используем $this->connection ...
       // ...
       return $data;
   }
}

Notice how much easier the implementation has become. In Database::fetchAllno need to check the status of the connection. To call Database::fetchAll, you need to create an instance of the class. To create an instance of the class, you need to pass the connection parameters to the constructor. If the connection parameters are not valid or the connection cannot be established for other reasons, an exception will be thrown and the object will not be created. This all means that when you call Database::fetchAll, you are guaranteed to have a connection to the database. This means that Fooyou only need to indicate in the constructor that he needs Database $databaseand he will have a connection to the database.

Without an instance Foo, you cannot call Foo::bar. Without an instance Database, you cannot create an instanceFoo. Without valid connection parameters, you cannot create an instance Database.

You simply cannot use the code if at least one condition is not satisfied.

Compare this with the class-oriented code: Foo::baryou can call it at any time, but an error will occur if the class is Databasenot ready. You Database::fetchAllcan call at any time, but an error will occur if there are problems with the file config/database.php. Database::connectsets the global state on which all other operations depend, but this dependency is not guaranteed.

Injection


Let's look at it from the side of the code that uses Foo. Procedural Example:

$bar = foo_bar();

You can write this line anywhere and it will be executed. Its behavior depends on the global state of connection to the database. Although from the code this is not obvious. Add error handling:

$bar = foo_bar();
if (!$bar) {
   // что-то не так с $bar, завершаем работу!
} else {
   // все хорошо, идем дальше
}

Due to implicit dependencies foo_bar, in case of an error it will be difficult to understand what exactly broke.

For comparison, here is a class-oriented implementation:

$bar = Foo::bar();
if (!$bar) {
   // что-то не так с $bar, завершаем работу!
} else {
   // все хорошо, идем дальше
}

No difference. Error handling is identical, i.e. it’s also hard to find the source of the problems. This is all because a call to a static method is just a function call that is no different from any other function call.

Now OOP:

$foo = new Foo;
$bar = $foo->bar();

PHP will crash with a fatal error when it comes to new Foo. We indicated that Fooa copy is needed Database, but did not transfer it.

$db  = new Database;
$foo = new Foo($db);
$bar = $foo->bar();

PHP will fall again because we did not pass the database connection parameters that we specified in Database::__construct.

$db  = new Database('localhost', 'user', 'password');
$foo = new Foo($db);
$bar = $foo->bar();

Now we have satisfied all the dependencies that we promised, everything is ready for launch.

But let's imagine that the parameters for connecting to the database are incorrect or we have some problems with the database and the connection cannot be established. In this case, an exception will be thrown at execution new Database(...). The following lines simply will not execute. So we don’t need to check the error after the call $foo->bar()(of course, you can check what has returned to you). If something goes wrong with any of the dependencies, the code will not be executed. And the thrown exception will contain information useful for debugging.

An object-oriented approach may seem more complex. In our example of procedural or class-oriented code, there is only one line that calls foo_barorFoo::bar, while the object-oriented approach takes three lines. It’s important to get the point. We did not initialize the database in the procedural code, although we need to do this anyway. A procedural approach requires error processing ex post and at every point in the process. Error handling is very confusing since It is difficult to track which of the implicit dependencies caused the error. Hardcode hides dependencies. The sources of error are not obvious. It is not obvious what your code depends on for its normal functioning.

The object-oriented approach makes all dependencies explicit and obvious. For you Fooneed an instance Database, and the instance Databaseneeds connection parameters.

In a procedural approach, responsibility rests with functions. Call the methodFoo::bar- Now he must return the result to us. This method, in turn, delegates the task Database::fetchAll. Now he has all the responsibility and he is trying to connect to the database and return some data. And if something goes wrong at any point ... who knows what will come back to you and where.

The object-oriented approach shifts part of the responsibility to the calling code, and this is its strength. Do you want to call Foo::bar? Ok, then give it a connection to the database. What connection? It doesn’t matter if it was an instance Database. This is the power of dependency injection. It makes the necessary dependencies explicit.

In the procedural code, you create a lot of hard dependencies and tangle different parts of the code with steel wire. It all depends on everything. You create a monolithic piece of software. I do not want to say that it will not work. I want to say that this is a very rigid structure, which is very difficult to disassemble. For small applications, this may work well. For large ones, this turns into a horror of intricacies that cannot be tested, expanded and debugged:



In object-oriented code with dependency injection, you create many small blocks, each of which is independent. Each block has a well-defined interface that other blocks can use. Each unit knows what it needs from others to make everything work. In procedural and class-oriented code, you associate FoowithDatabaseimmediately while writing code. In the object-oriented code, you indicate that you Fooneed some Database, but leave room for maneuver as it may be. When you want to use it Foo, you will need to associate a specific instance Foowith a specific instance Database: The



class-oriented approach looks deceptively simple, but tightly pins the code with nails of dependencies. The object-oriented approach leaves everything flexible and isolated until use, which may look more complex, but it is more manageable.

Static members


Why do we need static properties and methods? They are useful for static data. For example, the data on which the instance depends, but which never change. A completely hypothetical example:

class Database {
   protected static $types = array(
       'int'    => array('internalType' => 'Integer', 'precision' => 0,      ...),
       'string' => array('internalType' => 'String',  'encoding'  => 'utf-8', ...),
       ...
   )
}

Imagine that this class must bind data types from the database to internal types. This requires a type map. This map is always the same for all instances Databaseand is used in several methods Database. Why not make the map a static property? Data is never changed, but only read. And this will save a little memory, tk. data common to all instances Database. Because access to data occurs only inside the class; this will not create any external dependencies. Static properties should never be accessible from the outside, as these are just global variables. And we have already seen what this leads to ...

Static properties can also be useful to cache some data that is identical for all instances. Static properties exist, for the most part, as an optimization technique; they should not be regarded as a programming philosophy. And static methods are useful as helper methods and alternative constructors.

The problem with static methods is that they create a tough dependency. When you call Foo::bar(), this line of code becomes associated with a particular class Foo. This can lead to problems.

The use of static methods is acceptable under the following circumstances:

  1. Dependence is guaranteed to exist. In case the call is internal or dependency is part of the environment. For instance:

    class Database {
       ...
       public function __construct($host, $user, $password) {
           $this->connection = new PDO(...);
       }
       ...
    }
    

    It Databasedepends on the specific class - PDO. But PDO- this is part of the platform, this is a class for working with the database provided by PHP. In any case, you will have to use some kind of API to work with the database.

  2. Method for internal use. An example from the implementation of the Bloom filter :

    class BloomFilter {
       ...
       public function __construct($m, $k) {
           ...
       }
       public static function getK($m, $n) {
           return ceil(($m / $n) * log(2));
       }
       ...
    }
    

    This little helper function simply provides a wrapper for a particular algorithm that helps calculate a good number for the argument $kused in the constructor. Because it must be called before the class is instantiated; it must be static. This algorithm has no external dependencies and is unlikely to be replaced. It is used like this:

    $m = 10000;
    $n = 2000;
    $b = new BloomFilter($m, BloomFilter::getK($m, $n));
    

    This does not create any additional dependencies. The class depends on itself.

  3. Alternative constructor. A good example is the class DateTimebuilt into PHP. You can create an instance of it in two different ways:

    $date = new DateTime('2012-11-04');
    $date = DateTime::createFromFormat('d-m-Y', '04-11-2012');
    

    In both cases, the result will be an instance, DateTimeand in both cases the code is bound to the class DateTimeanyway. The static method DateTime::createFromFormatis an alternative object costructor, returning the same thing as new DateTime, but using additional functionality. Where you can write new Class, you can write and Class::method(). No new dependencies arise.

Other options for using static methods affect binding and can form implicit dependencies.

A word about abstraction


Why all this fuss with addictions? The ability to abstract! With the growth of your product, its complexity grows. And abstraction is the key to managing complexity.

For example, you have a class Applicationthat represents your application. He communicates with the class User, which is the user's presentation. Which receives data from Database. The class Databaseneeds it DatabaseDriver. DatabaseDriverneed connection parameters. Etc. If you just call Application::start()statically, which will call User::getData()statically, which will call the database statically, and so on, in the hope that each layer will figure out its dependencies, you can get a terrible mess if something goes wrong. It is impossible to guess whether the call will workApplication::start(), because it’s not at all obvious how internal dependencies will behave. Even worse, the only way to influence behavior Application::start()is to change the source code of this class and the code of the classes that it calls and the code of the classes that call those classes ... in the house that Jack built.

The most effective approach when creating complex applications is to create separate parts that you can rely on in the future. Parts that you can stop thinking about, in which you can be sure. For example, when calling static Database::fetchAll(...), there is no guarantee that the connection to the database is already established or will be established.

function (Database $database) {
   ...
}

If the code inside this function is executed, this means that the instance Databasewas successfully transferred, which means that the instance of the object Databasewas successfully created. If the class is Databasedesigned correctly, then you can be sure that the presence of an instance of this class means the ability to perform database queries. If there is no instance of the class, then the body of the function will not be executed. This means that the function does not have to take care of the state of the database; the class Databasewill do this itself. This approach allows you to forget about dependencies and concentrate on solving problems.

Without the ability not to think about the dependencies and dependencies of these dependencies, it is almost impossible to write at least any complicated application.Databaseit can be a small wrapper class or a giant multi-layer monster with a bunch of dependencies, it can start as a small wrapper and mutate into a giant monster over time, you can inherit a class Databaseand pass a descendant into a function, it's all not important for yours function (Database $database), as long as the public interface Databasedoes not change. If your classes are correctly separated from the rest of the application using dependency injection, you can test each of them using stubs instead of their dependencies. When you have tested the class enough to make sure that it works as it should, you can get the extra out of your head by simply knowing that you need to use an instance to work with the database Database.

Class-oriented programming is stupid. Learn to use OOP.

Also popular now: