Сеттеры в PHP. Правильное использование.

В объектно-ориентированном программировании особое внимание уделяется инкапсуляции данных. В PHP, как и в других ОО-языках для этого существует область видимости данных и методов класса. Несмотря на всю полезность private, protected и public механизмов определения данных, для разработки полноценных приложений одного объявления уровня доступа к данным не достаточно.

Возьмем в пример тривиальный объект User с public-свойствами класса:

<?php
class User
{
	// Имя пользователя.
	public $name;
	
	// Дата рождения пользователя.
	public $age;
	
	// Пол пользователя.
	public $sex;

	// URL домашней страницы пользователя.
	public $homepage_url;

	public function __construct(){/* ... */}
}

Программист, написавший данный класс, оставил свойства класса открытыми. Нарушил ли он инкапсуляцию? Трудно сказать, ответ может быть и "да" и "нет".
С теоретической точки зрения это правильный код и он вполне будет работать:

<?php
$user = new User();
$user->name = 'Вася Иванов';
$user->age = '18-08-1982';
$user->sex = 'Male';
$user->homepage_url = 'http://phpinfo.su';

Но с точки зрения практики это плохой код. И плохой он в первую очередь из-за отсутствия методов, позволяющих принимать даные в объект класса и возвращать их из него. Зачем могут потребоваться такие методы? Не секрет, что основной успех ООП - это возможность держать в объектах и свойства и методы, оперирующие этими свойствами. Объект User вполне может иметь требования к входящим данным, а клиент, запрашивающий данные от объекта User - к исходящим.

Например, одним из требований может быть невозможность помещать в свойство $age значение, отличное от объекта Datetime или дату рождения в неверном формате. Свойство $sex может иметь только одно из двух значений - 'Male' или 'Female'. Cвойство $name не должно быть пустыми, а свойство $homepage_url должно содержать валидный URL адрес.
Клиент, запрашивающий данные, также может иметь требования к свойствам класса, такие как возможность получать из объекта User свойство $age в различных форматах даты.

Все эти требования диктуют пересмотреть архитектуру класса User и создать методы, с помощью которых мы будем в объект класса User устанавливать (set) и получать (get) различные значения.

Явные методы доступа к данным

Перепишем класс User. Во-первых, сделаем все свойства класса защищёнными, указав уровень доступа как protected, а во-вторых дополнив его методами, оперирующими его защищёнными свойствами:

class User
{
	// Имя пользователя.
	protected $name;
	
	// Возраст пользователя.
	protected $age;
	
	// Пол пользователя.
	protected $sex;

	// URL домашней страницы пользователя.
	protected $homepage_url;

	public function __construct(){}
	
	public function setName($name) {
		$name = trim($name);
		if ($name == '') {
			throw new Exception('Не указано имя пользователя');
		}
		$this->name = $name;

		return $this;
	}
	
	public function getName() {
		return $this->name;
	}
	
	public function setAge($age) {
		if (is_object($age) && $age instanceof Datetime) {
			$this->age = $age;
		} else {
			try {
				$age = new Datetime($age);
				$this->age = $age;
			} catch (Exception $e) {
				throw new Exception('Некорректный формат даты рождения пользователя');
			}
		}
		
		return $this;
	}
	
	public function getAge() {
		return $this->age;
	}
	
	public function setSex($sex) {
		if (!in_array($sex, array('male', 'female'))) {
			throw new Exception('Неопределённый пол пользователя');
		}
		$this->sex = $sex;
		
		return $this;
	}
	
	public function getSex() {
		return $this->sex;
	}
	
	public function setHomepageUrl($url) {
		$this->homepage_url = $url;
		
		return $this;
	}
	
	public function getHomepageUrl() {
		return $this->homepage_url;
	}
}

Теперь, если мы захотим наполнить объект класса User свойствами, то мы как минимум можем избежать занесения в объект некорректных данных.
В случае, если

  • указать в качестве аргумента метода User::setName() пустую строку
  • указать в качестве аргумента метода User::setAge() некорректный формат даты, типа 18-088-1982
  • указать в качестве аргумента метода User::setSex() строку, отличную от значений 'male' и 'female'

то исключения, сработавшие в этих методах, передадут клиентскому коду соответствующее сообщение об ошибке. Попробуем указать неверный формат даты рождения пользователя в виде строки '18-088-1982':

try {
	$user = new User();
	$user->setName('Вася Иванов')
	     ->setAge('18-088-1982')
	     ->setSex('male')
	     ->setHomepageUrl('http://www.phpinfo.su');
} catch (Exception $e) {
	echo "Ошибка присвоения данных: " . $e->getMessage(); 
}

В результате в методе User::setAge() сработает сначала стандартное исключение объекта Datetime, которое будет перехвачено конструкцией try-catch, после чего будет выброшено исключение с человекопонятным описанием ошибки:

Ошибка присвоения данных: Некорректный формат даты рождения пользователя

На этом маленьком примере виден один из столпов объектно-ориентированного программирования - инкапсуляция. Теперь объект пользователя не только хранит в себе данные, но и умеет их валидировать, гарантируя, что объект класса User всегда содержит корректные данные. Фактически, мы создали модель (в рамках терминологии MVC) данных типа "пользователь". Теперь клиентский код может оперировать объектом пользователя:

try {
	$user = new User();
	$user->setName('Вася Иванов')
	     ->setAge('18-08-1982')
	     ->setSex('male')
	     ->setHomepageUrl('http://www.phpinfo.su');
} catch (Exception $e) {
	echo "Ошибка присвоения данных: " . $e->getMessage(); 
}

echo "Привет, " . $user->getName() . "! ".
      "Ты родился в " . $user->getAge()->format('Y') . " году. " . 
      "Твой пол - " . $user->getSex() . ". " .
      "Твой URL: " . $user->getHomepageUrl();

Выдаст

Привет, Вася Иванов! Ты родился в 1982 году. Твой пол - male. Твой URL: http://www.phpinfo.su

Обратите внимание, как мы получили год рождения пользователя - метод User::getAge() вернул объект класса Datetime, который хранит информацию о дате. У объекта класса Datetime есть метод format, который и отформатировал дату рождения пользователя в нужном нам формате. Очень элегантно и крайне "ООПшно"!

Магия PHP

Представим, что класс User содержал бы в себе не 4, а скажем 10 - 15 свойств. 15 умножить на 2 (set- и get- метод для каждого свойства), получаем 30 методов! Нам пришлось бы написать 30 методов в классе! Если посмотреть внимательно на вышеприведённый класс User, то можно заметить, что не все методы имеют логику внутри себя:

  • set-метод User::setHomepageUrl() только присваивает переданный аргумент в $this->homepage_url, никаких проверок на валидность URL адреса мы не реализовали.
  • get-методы вообще не содержат никакой логики, их главная задача - отдать определённое значение.

Что бы не писать кучу однотипных методов для десятка свойств в классе, мы можем использовать существующий в PHP магический метод __call(). Данный метод, объявленный в классе, вызывается всякий раз, когда у объекта запрашивается несуществующий метод. API метода выглядит так:

public mixed __call(string $name, array $arguments)

где

  • $name - имя вызываемого несуществующего метода объекта
  • $arguments - аргументы, с которыми был вызван несуществующий метод объекта

Таким образом, при вызове несуществующего метода объекта класса User::setUserFriend() (установить друга пользователя)

$user->setUserFriend($friend);

запустится магический метод __call(), первым аргументом которого будет строка "setUserFriend", а вторым - массив, содержащий значение $friend. Имея эти данные, мы можем понять, что вызов несуществующего метода User::setUserFriend($friend) говорит нам о том, что нужно некое значение $friend записать (set) в свойство объекта $user_friend. Наша задача заключается только в том, что бы "распарсить" строку "setUserFriend" на составляющие:

  • Понять, что какое действие выполнять - присваивание (set) или получение (get)
  • Понять, какое свойство объекта задействовать. В данном случае - свойство под названием $user_friend.

Перепишем класс User с учётом новых требований:

class User
{
	// Имя пользователя.
	protected $name;
	
	// Возраст пользователя.
	protected $age;
	
	// Пол пользователя.
	protected $sex;

	// URL домашней страницы пользователя.
	protected $homepage_url;

	public function __construct(){}
	
    /**
     * Получение и установка свойств объекта через вызов магического метода вида:
     * $object->(get|set)PropertyName($prop);
     *
     * @see __call
     * @return mixed
     */
    public function __call($method_name, $argument)
    {
        $args = preg_split('/(?<=\w)(?=[A-Z])/', $method_name);
        $action = array_shift($args);
        $property_name = strtolower(implode('_', $args));

        switch ($action)
        {
            case 'get':
                return isset($this->$property_name) ? $this->$property_name : null;

            case 'set':
                $this->$property_name = $argument[0];
                return $this;
        }
    }
    
	public function setName($name) {
		$name = trim($name);
		if ($name == '') {
			throw new Exception('Не указано имя пользователя');
		}
		$this->name = $name;

		return $this;
	}

	public function setAge($age) {
		if (is_object($age) && $age instanceof Datetime) {
			$this->age = $age;
		} else {
			try {
				$age = new Datetime($age);
				$this->age = $age;
			} catch (Exception $e) {
				throw new Exception('Некорректный формат даты рождения пользователя');
			}
		}
		
		return $this;
	}

	public function setSex($sex) {
		if (!in_array($sex, array('male', 'female'))) {
			throw new Exception('Неопределённый пол пользователя');
		}
		$this->sex = $sex;
		
		return $this;
	}
}

Как видно, мы убрали все get-методы получения данных и убрали метод User::setHomepageUrl(), определили в классе User метод __call(), который занимается определением нужного действия и определением имени свойства объекта, к которому применить действие. Для того, что бы имя свойства было определено верно, несуществующий метод нужно писать в CamelCase стиле:

  • setHomepageUrl, getHomepageUrl
  • setName, getName
  • setAge, getAge
  • setSex, getSex

Строка "HomepageUrl" будет преобразована в имя свойства "homepage_url".
Строка "Name" будет преобразована в имя свойства "name" и т.д.

Соответственно, имена свойств определяются в классе в нижнем регистре, слова разделяются знаком нижнее подчёркивание "_":

  • $homepage_url
  • $name
  • $age
  • $sex

Взглянем на результат:

try {
	$user = new User();
	$user->setName('Вася Иванов')
	     ->setAge('18-08-1982')
	     ->setSex('male')
	     ->setHomepageUrl('http://www.phpinfo.su');
} catch (Exception $e) {
	echo "Ошибка присвоения данных: " . $e->getMessage(); 
}

echo "Привет, " . $user->getName() . "! ".
      "Ты родился в " . $user->getAge()->format('Y') . " году. " . 
      "Твой пол - " . $user->getSex() . ". " .
      "Твой URL: " . $user->getHomepageUrl();

Код отработал как нужно, несмотря на то, что в нём нет явно объявленных get-методов для получения данных, а также нет метода User::setHomepageUrl(). Всю "грязную" работу за нас взял магический метод __call().

Выводы

Используя __call() мы оградили себя от рутинной работы - написания множества однотипных методов, в чьи обязанности входило лишь присвоение свойствам объекта значений и их получение. В случае необходимости, мы можем описать в классе явный метод, это никак не повлияет на клиентский код, уже использующий одноимённый виртуальный метод:

class User
{
    // .....

    /**
    * Получить URL-адрес без строки протокола.
    * 
    * @return string
    */
    public function getHomepageUrl() {
        return str_ireplace('http://', '', $this->homepage_url);
    }	
}
// Клиентский код, который ранее использовал виртуальный метод.
// Введение в класс явно описанного метода User::getHomepageUrl() 
// никак не повлияло на работу кода.
echo $user->getHomepageUrl();

Результат: 

www.phpinfo.su