ООП в PHP: Магические методы. Методы-перехватчики

magic-methods-phpЛюбой нативный метод в php, который начинается с __ называются магическим. Вся «магия» таких методов состоит в том, что они могут вызываться при совершении какого-то действия автоматически и без ведома программиста. С одним из очень важных магических методов мы уже вскользь познакомились в предыдущей статье в этой рубрике, это метод __construct, который называют конструктором. Он вызывается при создании экземпляра класса и, как правило, выполняет действия по его инициализации.

Не менее значимые, на мой взгляд, магические методы это методы-перехватчики __get, __set, __isset, __unset, __call. Назвали их так за то, что они словно перехватывают обращение к недоступным или несуществующим членам класса.

Методы __get и __set

Эти методы срабатывают при обращении к несуществующим или недоступным полям класса или его предка. Они должны быть объявлены со спецификатором доступа public. При этом метод __get вызывается при попытке считать значение скрытого или несуществующего свойства. В качестве параметра он принимает строку, содержащею имя свойства к которому произошла попытка обратиться. Возвращаемое этим методом значение будет воспринято как значение свойства, к которому произошло обращение, при этом неважно скрыто это свойство, или оно вовсе не существует.

class Point {
  private $x;
  private $y;
 
  public function __construct($x, $y) {
    $this->x = $x;
    $this->y = $y;
  }
 
  public function __get($name) {
    echo "Произошло обращение к свойству $name<br />";
    return $this->$name;
  }
}
 
$p = new Point(8, 16);
echo "x: $p->x<br />";
echo "y: $p->y<br />"; 
echo "Несущесвующие поле: $p->nonexistentProperty";

Результат работы этого скрипта:

Произошло обращение к свойству x
x: 8
Произошло обращение к свойству y
y: 16
Произошло обращение к свойству nonexistentProperty
Несуществующие поле:

Как видите, метод __get при такой реализации при обращении к несуществующему свойству возвращает значение null, которое в нашем примере преобразовалось к пустой строке.

Метод __set вызывается при попытке изменить значение несуществующего или скрытого свойства. В качестве параметров он принимает имя свойства и значение, которое ему пытаются присвоить. Добавим этот метод в наш класс Point.

class Point {
  private $x;
  private $y;
 
  public function __construct($x, $y) {
    $this->x = $x;
    $this->y = $y;
  }
 
  public function __get($name) {
    echo "Произошло обращение к свойству $name<br />";
    return $this->$name;
  }
 
  public function __set($name, $value) {
    $this->$name = $value;
    echo "Cвойству  $name присвоено значение $value ";
  }
}

Теперь при каждой попытке изменить значение скрытого или несуществующего свойства будет выводиться соответствующее сообщение.

$p = new Point(8, 16);
$p->x = 10;
$p->y = 20;
$p->z = 30;

Результат работы этого скрипта:

Cвойству x присвоено значение 10
Cвойству y присвоено значение 20
Cвойству z присвоено значение 30

Методы __get и __set полезны тем, что с их помощью можно эмулировать наличие свойства, которого нет. При этом можно сделать так, что не заглядывая внутрь класса, об этом нельзя будет никак догадаться. Например, у нас есть класс, описывающий квадрат с одним скрытым полем, которое содержит длину стороны квадрата (side).

class Squere {
  private $side;
 
  public function __construct($a) {
    $this->side = $a;
  }
}

Эмулируем наличие у этого класса свойства «площадь» (area). При этом учтём, что площадь квадрата и длина его стороны зависят друг от друга.

class Squere {
  private $side;
 
  public function __construct($a) {
    $this->side = $a;
  }
 
  public function __set($name, $value) {
    if ($name == 'area') {
      $this->setArea($value);
    } else if ($name == 'side') {
      $this->side = $value;
    }
  }
 
  public function __get($name) {
    if ($name == 'area') {
      return $this->getArea();
    } else if ($name == 'side') {
      return $this->side;
    }
  }
  private function getArea() {
    return $this->side * $this->side;
  }
 
  private function setArea($area) {
    return $this->side = sqrt($area);
  }
}

Теперь экземпляры этого класса будут вести себя так, словно свойство area присутствует в классе, а свойство side доступно извне.

$squere = new Squere(25);
echo $squere->area; // 25 * 25 = 625

При этом нам не нужно будет заботиться о том, что площадь квадрата зависит от длины его стороны.

$squere = new Squere(10);
echo $squere->side; // 10
$squere->area = 25;
echo "После изменения площади изменилась и сторона:<br />".
  "$squere->side"; // 5 = sqrt(25)

Абсолютно того же можно было добиться и при использовании методов, но в таком случае для получения или изменения значения каждого поля пришлось бы писать свой собственный метод, что довольно утомительно, особенно если полей у класса много. В некоторых средах разработки, например, в NetBeans даже предусмотрена автоматическая генерация таких методов.

Методы, которые организуют доступ к полям класса, называют геттерами (служат для получения значений) и сеттерами (служат для изменения значений). Как правило, код в этих методах шаблонный и очевидный. Пишутся они для предотвращения присвоения некорректных значений свойствам класса. Например, у нас есть класс, описывающий человека. Экземпляр этого класса хранит информацию о его имени и возрасте. При помощи геттеров и сеттеров обеспечивается корректность этих данных.

class Man {
  private $age;
  private $name;
 
  public function __construct($name, $age) {
    $this->setAge($age);
    $this->setName($name);
  }
 
  public function setAge($age) {
    if (is_integer($age) && $age >= 0 && $age < 150) {
      $this->age = $age;
    } else {
      exit ("Некорректный возраст");
    }
  }
 
  public function setName($name) {
  $name = trim($name);
    if ($name != '') {
      $this->name = $name;
    } else {
      exit ("Некорректное имя");
    }
  }
 
  public function getName() {
    return $this->name;
  }
 
  public function getAge() {
    return $this->age;
  }
}

При попытке присвоить некорректный возраст произойдёт соответствующая ошибка и, хотя, завершать работу всего скрипта в таких случаях не совсем правильно – желательнее использовать механизм обработки исключений, но о нём мы поговорим в следующих статьях.

$man = new Man('Jhon Doe', 21);
$man->setAge(-2); // Некорректный возраст. Произойдёт завершение работы скрипта

Можно избавиться от геттеров и сеттеров если иcпользовать методы __get и __set.

class Man {
  private $age;
  private $name;
 
  public function __construct($name, $age) {
    $this->age = $age;
    $this->name = $name;
  }
 
  public function __set($name, $value) {
    if ($name == 'age') {
      if (is_integer($value) && $value >= 0 && $value < 150) {
        $this->age = $value;
      } else {
        exit ("Некорректный возраст");
      }
    } else if ($name == 'name') {
       if ($name != '') {
        $this->name = $name;
      } else {
        exit ("Некорректное имя");
      }
    } else {
      exit ("Неизветное свойство");
    }
  }
 
  public function __get($name) {
    if ($name == 'age' || $name == 'name') {
      return $this->$name;
    }
    exit('Неизвестное свойство!');
  }
}
 
echo $man->age; // 21
$man->age = -5; // Некорректный возраст. Произойдёт завершение работы скрипта

Быть может в этом случае код стал немного запутаннее, но иногда, когда проверка присваиваемых свойствам значений одинакова или чем-то похожа такой приём может немного сократить и улучшить ваш код. Кроме того в этом примере мы запретили обращаться к несуществующим свойствам класса. Такой запрет возможно спасёт вас в будущем от некоторых ошибок.

Эмулирование свойств иногда называют перегрузкой, но во многих других языках под перегрузкой понимают немного иное явление поэтому, чтобы не вас не запутать, я предпочёл не использовать это неоднозначное понятие.

Метод __isset

При имитации свойств с помощью методов __get и __set всегда нужно помнить, что реально таких свойств не существует. Но использовать ваш класс может и другой программист, и имитированные свойства могут быть приняты им за реально существующие. Казалось бы, в этом нет ничего плохого, но при попытке проверить наличие имитированного свойства при помощи функции isset можно получить неожиданный результат:

$p = new Squere(10);
// наличие свойства area в классе Squere имитировано 
var_dump(isset($p->area)); //  bool(false)

Чтобы избежать подобных недоразумений существует магический метод __isset, который вызывается при попытке проверить наличие недоступного или несуществующего свойства с помощью функции isset. Возвращаемое методом __isset логическое значение и говорит, следует ли считать свойство существующим.

Добавим реализацию метода __isset в класс Squere:

public function __isset($name) {
  if ($name == 'area') {
    return true;
  }
  return false;
}

Теперь функция isset возвращает ожидаемый результат:

$p = new Squere(10); 
var_dump(isset($p->area)); //  bool(true)

Метод __unset

Это метод вызывается при попытке удалить несуществующее или недоступное свойство и, если его не реализовать, то такое свойство нельзя будет удалить, используя функцию unset.

$p = new Squere(10);
// свойство area класса Squere имитированное 
unset($p->area);
var_dump($p->area); // int(100)

При реализации метода __unset следующим образом, свойство area в этом случае будет вести себя более предсказуемо.

public function __unset($name) {
  if ($name == 'area') {
    $this->$name = null;
  }
}
 
$p = new Squere(10);
unset($p->area);
var_dump($p->area); // float(0)

Метод __call

Это, на мой взгляд, наиболее интересный из всех магических методов-перехватчиков. Он вызывается, когда происходит вызов несуществующего или недоступного метода и принимает в качестве аргументов имя вызываемого метода и переданные ему аргументы. Метод __call можно использовать для передачи вызова метода одного класса другому классу. Этот же явление происходит при наследовании, но связи между родительскими и дочерними элементами при наследовании фиксированы. При реализации, которая представлена в следуещем примере, существует возможность динамически определять объект, который получит методы от другого класса, экземпляром которого он не является.

class PointPrinter {
  public function printX(Point $p) {
    echo "$p->x<br />";
  }
 
  public function printY(Point $p) {
    echo "$p->y<br />";
  }
}
 
class Point {
  private $x;
  private $y;
  private $printer;
 
  public function __construct(PointPrinter $printer, $x, $y) {
    $this->x = $x;
    $this->y = $y;
    $this->printer = $printer;
  }
 
  public function __get($name) {
    return $this->$name;
  }
 
  public function __call($methodName, $args) {
    return $this->printer->$methodName($this);
  }
}
 
$p = new Point(new PointPrinter(), 20, 10);
$p->printX(); // 20
$p->printY(); // 10

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

C php 5.3 существует возможность делать тоже самое и со статическими метода при помощи магического метода __callStatic.

Методы __sleep и __wakeUp

Эти методы, возможно, понадобятся вам, когда вы столкнётесь с сериализацией экземпляров класса. Подробное рассмотрение этого понятия немного выходит за рамки этой статьи, но для того, чтобы понять, зачем эти методы всё же нужны, знать что такое сериализация вам не помешает.

Сериализация – это процесс представления какой-либо структуры данных (экземпляра класса, ассоциативного массива и т.д.) в виде последовательности байтов.

В php для сериализации используется функция serialize. Для десериализации объекта, то есть для восстановления изначального состояния сериализованного объекта в php есть функция unserialize.

$p = new Point(new PointPrinter(), 20, 10);
$str = serialize($p);
echo "$str<br />"; // O:5:"Point":3:{s:8:"Pointx";i:20;s:8:"Pointy";i:10;…
$p = unserialize($str);
echo "({$p->x}, {$p->y})"; // (20, 10)

Метод __sleep вызывается при попытке сериализовать экземпляр класса. Он должен возвращать массив, который содержит имена полей класса, которые должны быть учтены при сериализации. Его полезно использовать, если вы не хотите чтобы при сериализации были учтены некоторые поля класса. Например, при такой реализации у экземпляра класса Point поле $printer не будет учтёно при сериализации.

public function __sleep() {
  return array('x', 'y');
}

Метод __wakeup вызывается при попытке десериализовать экземпляр класса. В его теле можно, например проверить или восстановить соединение с БД или файлом откуда будет читаться сериализованный экземпляр.

Метод __destruct

Этот метод называется деструктором и вызывается когда необходимо очистить память занимаемую экземпляром класса. Обычно это случается когда скрипт завершает работу или экземпляр класса теряет все ссылки на себя. В этом методе можно, например, закрыть соединение с базой данных, если он реализовывается в классе для работы с ней. Покажем более подробно, когда этот метод вызывается.

class Example {
  public function __destruct() {
    echo 'Сработал деструктор<br />';
  }
}
 
$obj = new Example();
$obj = null;
$obj = new Example();

В результате выполнения этого скрипта будет выведено:

Сработал деструктор
Сработал деструктор

Первый раз деструктор сработает, когда ссылке $obj на экземпляр класса Example будет присвоено null и на этот экземпляр больше не останется ссылок. Второй раз деструктор сработает перед завершением работы скрипта.

Метод __toString

Этот метод вызывается при приведении экземпляра класса к строке. Его иногда бывает удобно использовать при отладке.
Если реализовать в классе Point метод __toString следующим образом:

public function __toString() {
  return "({$this->x}, {$this->y})";
}

то, результатом вополнения такого кода:

$p = new Point(new PointPrinter(), 20, 10);
echo $p;

будет служить строка: (20, 10)

Заключение

Методы-перехватчики (__get, __set, __isset, __unset, __call) могут показаться очень полезными, но злоупотреблять их использованием всё же не стоит. Код, который целиком построенный на методах-перехватчиках, сложно поддаётся анализу, ведь методы и поля, которые класс получил при из использовании в нём не объявлены и их наличие может легко ввести в заблуждение. Код больше времени читают и анализируют, нежели пишут и, возможно, вы сэкономите время, потраченное на написание кода, если будете использовать методы-перехватчики, но вы или сторонние разработчики будут анализировать ваш код намного дольше.

Использование магических методов может в корне изменить поведение экземпляров класса и иногда это оправдано, но поведение экземпляров в этом случае должно быть интуитивно понятно и адекватно. Например, бросать исключение, или завершать работу скрипта при обращении к несуществующему свойству это хорошая практика, но возвращать false в этом случае не стоит так как возвращённое значение может быть расценено двояко.

Успехов вам!

  • Dmitry

    а тут $this или $args?

    1
    2
    3
    
    public function __call($methodName, $args) {
      return $this->printer->$methodName($this);
    }
    • $this, там всё верно. Методы класса PointPrinter принимают в качестве параметра экземпляр класса Point, который и содержится в $this.

  • Антон, хотелось бы поблагодарить Вас за уроки, продолжайте в том же духе, для себя наконец то понимаю некоторые вещи, которые трудно было усвоить из других источников, спасибо! Ну и вопрос: в коде, где описываются методы __get и __set, где вычисляется площадь квадрата, 18-ая строчка return $this->getArea($value); верна ли она? Мы передаём в функцию getArea параметр, но при описании функции, входных параметров нет, и вообще переменная $value к этому моменту не инициализирована?