Практика ООП (Рейтинг: +1)

Печать / RSS
Я не приверженец практики без теории, потому постараюсь попутно сопровождать весь код важной для незнакомых с ОО архитектурой информацией.
И так хочется продемонстрировать вам мое решение тривиальной ситуации с помощью ОО подхода, которое в последующем сильно экономило мое время.
Предыстория
Как то раз возникла задача организовать класс "Хранитель", которые позволяет хранить в себе текущее состояние родительского объекта и восстанавливать его когда это потребуется, при чем раскрывать это состояние позволяется только родительскому объекту, дабы не нарушить инкапсуляции (кто знаком с шаблонами проектирования, мне понадобился обычный Хранитель). Реализовал его за несколько минут и решил пойти дальше: а почему бы не организовать такой Хранитель, который сможет переносить состояния объектов между обращениями к скрипту? Ведь для этого достаточно воспользоваться каким нибудь хранилищем типа сессии или БД. Идея мне очень понравилась (с учетом того, что я несколько месяцев реализую собственную ORM) и я принялся за разработку.
Важные вопросы архитектуры
1. Первым вопросом, который предстояло решить был - придется ли мне переписывать каждый класс Хранитель под отдельное Хранилище? Другими словами стоит ли создать два класса: ХранительСессии и ХранительБД - чтобы каждый тип Хранителя мог переносить состояния в разных Хранилищах? Здесь заметно явное дублирование кода, так как многие методы будут идентичны в этих двух Хранителях, различаются только методы доступа к данным Хранилища.
2. Второй вопрос прямо вытекает из первого - правильно ли использовать алгоритмы доступа к данным сессии, запуск сессии, добавления и удаления в ней данных, через интерфейсы и методы самого Хранителя? Ведь каждый класс должен выполнять строго ограниченные задачи, а любой Хранитель уже выполняет задачу хранения и возврата состояния родителя?!
3. Третий вопрос возник, когда я задумался о вынесении механизмов работы с Хранилищами в отдельный класс - как организовать Хранитель так, чтобы он мог безошибочно найти себя в Хранилище после перезапуска сервиса, т.е. в той ситуации, когда не будет ни родительского объекта, ни самого Хранителя, а останутся только данные в БД или сессии? Хотелось не отходить далеко от принципов ORM и воспользоваться идентификацией по известным атрибутам, на пример, задав объекту его OID или Login и Password, восстановить по ним Хранителя.
Важные решения архитектуры
Возможно, опытные ООПшники уже набросали в голове ответы на все поставленные ранее вопросы, но я распишу все более подробно для остальных (очень жаль, что в статью нельзя встраивать изображения).
1. Для решения первого вопроса мы воспользуемся композицией и делегированием, а так же паттерном Стратегия. Что означают и как применяются эти страшные слова, я объясню чуть позже.
2. Для решения второго вопроса мы используем унифицированный и довольно упрощенный интерфейс работы с Хранилищами. Ведь нам нет необходимости использовать все возможности таких интерфейсов как MySQLi, для решения задач Хранителя достаточно: select, update, remove, input. Причем любое хранилище, будь то сессия, БД или файл может без проблем реализовать эти функции.
3. Не отходя далеко от принципов ORM, я поступил именно так, как сказано в них, а именно использовал известные данные о структуре объекта, для аутентификации его из Хранилища.
Самое интересное
И так реализация и подробное описание всех механизмов. Начну сверху и медленно проберусь в глубь гвнокода.
Что я подразумеваю под Стратегией, композицией и делегированием? Для начала давайте повторим, что такое интерфейс в PHP. Интерфейс это гарантия того, что все классы, его реализующие, будут иметь те методы, что в нем описаны. Интерфейсы так же называют Договором, так как все классы "подписавшиеся" на интерфейс "обязаны" выполнить все его методы ("условия"). Если немного подумать, то любой Хранитель, способный переносить данные между вызовом сервисов, все лишь должен иметь объект, обращающийся к Хранилищам и передавать ему свое состояния для записи и восстановления. Другими словами, зачем вшивать алгоритмы работы с Хранилищами в сам Хранитель, когда можно написать отдельный класс, целью которого будет получение данных и передачу их в Хранилище. Это называется инкапсуляцией методов. Мы выносим метод за пределы класса, и упаковываем его в другой класс. Здесь важно сделать оговорку для слова "упаковываем". Нет! Мы не используем класс как хранилище методов и свойств, это не так. Мы создаем отдельный, полноценный класс, целью которого будет работа с различными Хранилищами. Это очень часто встречающийся подход в ООП. Он позволяет решить еще одну важную задачу - сделать код более гибким. Представьте, что мы реализуем два Хранителя, один хранит данные в Сессии, другой в БД. Для этого одному мы передаем экземпляр класса, работающий с Хранилищем БД, а другому экземпляр класса, работающий с Хранилищем Сессии. Причем тот и другой реализуют упрощенный интерфейс: select, update, remove, input - таким образом, Хранителю не важно, что это за объект и с каким Хранилищем он работает, достаточно знать, что он реализует методы записи и восстановления, которые нужны Хранителю для работы. Такой подход называется композицией - мы передаем объекту другой объект, который будет выполнять некоторые функции за него. Непосредственно использование Хранилища Хранителем называется делегированием задач. Хранитель передает часть работы Хранилищу, делегируя ему некоторые необходимы операции, а Хранилище гарантирует, что эти операции будут выполнены.

Инструменты
Что нам необходимо для воплощения идеи в жизнь?
1. Хранитель, способный получать текущее состояние родителя и делегировать задачу записи состояния в Хранилище, а так же восстанавливать состояние из него.
2. Унифицированный и упрощенный интерфейс Хранилища, дабы не заботится о том, с каким именно Хранилищем работает Хранитель.
3. Полноценный класс (не Хранилище) для работы с сессиями, так как Хранилище лишь унифицирует интерфейс, никаких дополнительных задач обращения, и работы с сессиями у него нет. Интерфейс работы с БД у нас уже имеется.
4. Интерфейс родителя Хранителя, который будет следовать правилам ORM.
Класс работы с сессией
Вот я задумался над классом для работы с сессией. Он должен быть довольно мощным, и при этом не было времени зацикливаться на его разработке, потому он должен оставаться гибким для расширения в будущем. Это будет полноценный класс для работы с сессией как с реляционной таблицей, но не в данном контексте, а когда ни-будь потом.
При работе с сессией стоит уловить важный принцип: зависимость объекта от его состояния. Что это значит? Что будет если попытаться записать данные в еще не открытую сессию? Правильно, ничего не будет. Уловили ход мысли? Наверно нет ). Я хочу сказать что каждый объект, работающий с сессией находится в различных состояниях. Когда сессия не открыта, мы не можем записывать данные или получать их из сессии, это состояние я назвал notConnection (по аналогии с БД). В этом состоянии объект обладает несколько другими возможностями, нежели в состоянии connection, которое он приобретает после соединения с сессией.
Опять-таки опытные ООПшники уже выбрали решения и показывают пальцем в сторону паттерна "Стратегия". Что это такое и с чем его есть?
Стратегия
Стратегия это такой механизм, позволяющий изменять объект в зависимости от его внутреннего состояния так, что будет казаться, будто мы работаем с разными объектами. Нам такое поведение как раз кстати, для нашего класса работы с Сессией.
Мы создаем класс:

<?php
class session implements Isession, state\context{
protected
$stateConnected, // Хранит состояние подключенности к сессии
$stateNotConnected, // Хранит состояние неподключенности к сессии
$state; // Храним текущее состояние
public
function __construct(){ // Определяет текущее состояние объекта
if(session_id())
$this->state = $this->stateConnected = new stateConnected($this);
else
$this->state = $this->stateNotConnected = new stateNotConnected($this);
}
function setState(state\state $state){ // Устанавливает состояние объекту
$this->state = $state;
}
function getStateConnected(){ // Возвращает состояние соединения с сессией
if(empty($stateConnected))
$stateConnected = new stateConnected($this);
return $stateConnected;
}
function getStateNotConnected(){ // Возвращает состояние неподключенности к сессии
if(empty($stateNotConnected))
$stateNotConnected = new stateNotConnected($this);
return $stateNotConnected;
}
/* Пока код может и непривычный, но такой же тривиальный.
Мы просто реализовали механизм упаковки состояний и доступа к ним*/
// Далее ужасно тривиальный код
function connection($name = 'PHPSESSID'){
return $this->state->connection($name);
}
function createTable($name, array $fieldsNames){
return $this->state->createTable($name, $fieldsNames);
}
function removeTable($name){/*такой же код*/}
function input($name, array $data){/*такой же код*/}
// и т.д.
}
?>

Заметили несколько важных особенностей кода? Класс не реализует алгоритм доступа к данным вообще )). Он просто обращается к своему текущему состоянию (делегирует), а уже то выполняет, необходимы действия по работе с сессией. Причем состояние notConnection не может получить данные из сессии или записать их туда, в то время как состояние connection это делать может. То есть при смене состояний объекта его поведение резко изменятся с "не могу ничего" до "могу все". Продолжим:

<?php
interface Isession{
/* Класс работы с сессией, а так же все состояния должны
реализовать этот интерфейс чтобы быть уверенным в том,
что класс не будет менять свой интерфейс при переходах
между состояниями, а изменится только поведение */
function connection($name = 'PHPSESSID');
function createTable($name, array $fieldsNames);
function removeTable($name);
function input($name, array $data);
function select($name, array $conditionSelection);
function remove($name, array $conditionSelection = null);
function update($name, array $data, array $conditionSelection = null);
function findLines($name, array $conditionSelection);
function destroy();
}

class stateNotConnected extends state\state implements Isession{
function connection($name = 'PHPSESSID'){
session_name($name);
session_start();
// Внимательно посмотрите эту строчку
$this->context->setState($this->context->getStateConnected());
/* Класс состояния самостоятельно изменяет состояние
родителя при подключении к сессии! Здесь проявляется механизм
автоматической смены поведения при изменении состояния объекта */
return true;
}
function createTable($name, array $fieldsNames){
echo 'Невозможно выполнить действие, соединение не установленно';
}
function removeTable($name){
echo 'Невозможно выполнить действие, соединение не установленно';
}
// Далее в том же духе
}

class stateConnected extends state\state implements Isession{
/* Классы состояний как и любые другии могут иметь
собственные методы */
private function getLines($name, array $conditionSelection = null){
$name = (string)$name;
if(empty($_SESSION[$name]))
return false;

if($conditionSelection && count($conditionSelection) != 0)
$lines = $this->findLines($name, $conditionSelection);
else{
reset($_SESSION[$name]);
$lines = $_SESSION[$name][key($_SESSION[$name])];
}
return $lines;
}

public
/* Повторного соединения не требуется, потому просто
останавливаем работу метода */
function connection($name = 'PHPSESSID'){
return true;
}
/* Это уже полноценный метод работы с сессией, вынесенный из
класса в класс состояния */
function createTable($name, array $fieldsNames){
$name = (string)$name;
if(empty($name))
return false;
if(empty($_SESSION[$name])){
$_SESSION[$name] = array();
foreach($fieldsNames as &$v)
$_SESSION[$name][$v] = array();
return true;
}
else
return false;
}
function removeTable($name){
$name = (string)$name;
if(!empty($_SESSION[$name])){
unset($_SESSION[$name]);
return true;
}
else
return false;
}
function input($name, array $data){/*Все в том же духе*/}
?>

Давайте подробно разберем этот код. Мы определили интерфейс ISession который реализуется как классом session (класс высокого уровня), так и его состояниями stateNotConnection и stateConnection (классы низкого уровня). Теперь мы уверены что вызывая метод класса session он сможет без затруднений делегировать его тому же методу класса состояния, так как они оба "обязаны" реализовать одинаковый интерфейс. Так же мы определили два класса состояний, первый выполняет очень мало функций, второй очень много. При переходе из состояния в состояние возможности класса будут увеличиваться или уменьшаться. Причем сам переход описан в классе состояния (это не обязательно, есть много других способов организации перехода между состояниями). Так же каждый класс состояния реализует интерфейс state\state (ранее я подключил пакет use PPHP\patterns\state as state; для этого). Это позволяет классу session держать в переменной $state не конкретное состояние notConnection или connection, а любое состояние, реализующее интерфейс state\state. В этом проявляется гибкость ОО подхода. Чтобы расширить класс нам больше нет необходимости лезть в код, достаточно добавить еще одно состояние. Такой код становится более понятным, так как можно искать реализацию по состояниям, а не в конструкциях типа:

<?php
if($this->state = 'notConnection'){
// ...
}elseif($this->state = 'connection'){
// ...
}elseif(//...)
?>

Возможно, пока вам покажется, что такая конструкция очень громоздка и создает кучу ненужных классов. На самом деле здесь проявляется вся прелесть ООП.

Унификация и адаптация
Пока наша практика не касалась поставленной задачи. Сейчас пора приступить к написанию общего для всех хранилищ интерфейса. Мы используем это для того, чтобы хранителю не пришлось заботится о том, с каким хранилищем он работает (MySQLi, Файл, Сессия и т.д.), все что ему известно, это то, что хранилище реализует методы записи, восстановления, обновления и удаления данных.
Если бы мы не унифицировали и не адаптировали эти интерфейсы (интерфейсы доступа к различным хранилищам) нам пришлось бы программировать класс Хранителя под каждый из интерфейсов, что привело бы к дублированию кода.
Для начала определим тот интерфейс, который нам будет необходим для полноценной работы Хранителя. Ничего сложного здесь конечно нет. Что должен уметь Хранитель? Получать состояние объекта и в нужный момент восстанавливать это состояние (можно подробно изучить этот механизм в шаблоне Memento). Хранитель же, который способен переносить состояния между обращениями к серверу (будем называть его Постоянным Хранителем) должен обладать теми же свойствами (имеется в виду не программными свойствами) что и Хранитель, то есть способность получать и возвращать состояние родителя, но кроме того еще и записываться в Хранилище, восстанавливаться из него, а так же удаляться и обновляться:
1) Сохранить Хранитель;
2) Восстановить Хранитель;
3) Обновить состояние Хранителя в Хранилище;
4) Удалить состояние Хранителя из Хранилища.
Для этого нам понадобится от Хранилища всего 4 метода:
1) Input - добавляет новые данные в Хранилище;
2) Select - возвращает существующие в Хранилище данные;
3) Update - обновляет существующие данные в Хранилище;
4) Remove - удаляет данные из Хранилища.
Как можно заметить предложенные операции могут быть реализованы для любого хранилища, будь то конкретная СУБД, файл или сессия.
Приступая к унификации и адаптации

<?php
/*
Унифицированный интерфейс, который должен быть реализован всеми
Хранилищами, используемыми в решении задачи
*/
interface Istorage{
// Получает имя таблицы и добавляемые данные
function input($name, array $data); // Метод записи
// Получает имя таблицы и информацию для поиска
function select($name, array $conditionSelection = null); // Метод получения
// Получает имя таблицы и информацию для поиска
function remove($name, array $conditionSelection = null); // Метод удаления
// Получает имя таблицы, обновляемые данные и информацию для поиска
function update($name, array $data, array $conditionSelection = null); // Метод удаления
}
?>

Внимательно посмотрите этот код. Он определяет, какими методами должен обладать класс, чтобы его мог без труда использовать Постоянный Хранитель. Естественно такой упрощенный интерфейс приводит к лишению многих возможностей, предоставляемых конкретными классами работы с СУБД, но нам они и не нужны.
Важно отметить, что получаемые и возвращаемые данные так же имеют определенную структуру помимо типа. Так аргументы data всегда имеют структуру [имя поля => значение, ...], а аргументы conditionSelection [имя поля => искомое значение, ...].

<?php
// Адаптация класса доступа к сессии
class sessionStorage implements Istorage{ // Реализация унифицированного интерфейса
protected static $storage;
/* Здесь хранится объект для доступа к сессии. Экземпляры класса
используеют этот объект в своих целях */
function __construct(){
if(empty(self::$storage) || !(self::$storage instanceof session\Isession)){
// Если объект для работы с сессией еще не существует, создаем его
self::$storage = new session\session();
self::$storage->connection();
}
}

function input($name, array $data){
// Делегирование
return self::$storage->input($name, $data);
}
function select($name, array $conditionSelection = null){
/* По сути то же делегирование, но так как класс для работы с
сессией у нас возвращает данные со структурой [имя поля => [номер строки => значение, ...], ...], то мы должны привести его к
разрешенной в интерфейсе структуре
[номер строки => [имя поля => значение, ...], ...], это обязательно
в унификации */
if(!($sample = self::$storage->select($name, $conditionSelection)))
return array();
$result = array();
reset($sample);
foreach($sample[key($sample)] as $krow => &$varray)
foreach($sample as $kfield => &$v){
if(!is_array($result[$krow]))
$result[$krow] = array();
$result[$krow][$kfield] = $sample[$kfield][$krow];
}
return $result;
}
function remove($name, array $conditionSelection = null){
return self::$storage->remove($name, $conditionSelection);
}
function update($name, array $data, array $conditionSelection = null){
return self::$storage->update($name, $data, $conditionSelection);
}
}
?>

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

<?php
class MySQLStorage implements Istorage{
protected static $storage;
public
function __construct(\mysqli $storage=null){
if(empty(self::$storage) || !(self::$storage instanceof mysqli)){
if(!empty($storage))
self::$storage = $storage;
else
throw new Exception('Нет активного соединения с БД');
}
}

function input($name, array $data){
$changes = array();
foreach($data as $k => &$v)
$changes[] ='`'.$k.'` = "'.$v.'"';
//echo 'Запрос: '.'INSERT INTO `'.$name.'` SET '.implode(' , ', $changes);
if(self::$storage->query('INSERT INTO `'.$name.'` SET '.implode(' , ', $changes))){
$queryResult = self::$storage->query('SELECT LAST_INSERT_ID()');
return array_pop($queryResult->fetch_array());
}else
return false;
}
function select($name, array $conditionSelection = null){
$where = array();
foreach($conditionSelection as $k => &$v)
$where[]='`'.$k.'` = "'.$v.'"';
$queryResult = self::$storage->query('SELECT * FROM `'.$name.'` WHERE '.implode(' AND ', $where));
if($queryResult === false || $queryResult->num_rows == 0)
return array();
$result = array();
while($result[] = $queryResult->fetch_assoc());
array_pop($result);
return $result;
//echo 'Запрос: '.'SELECT * FROM `'.$name.'` WHERE '.implode(' AND ', $where);
}
function remove($name, array $conditionSelection = null){
if(!empty($conditionSelection)){
$where = array();
foreach($conditionSelection as $k => $v)
$where[]='`'.$k.'` = "'.$v.'"';
$where = 'WHERE '.implode(' AND ', $where);
}
else
$where = '';
//echo 'Запрос: '.'DELETE FROM `'.$name.'` '.$where;
return self::$storage->query('DELETE FROM `'.$name.'` '.$where);
}
function update($name, array $data, array $conditionSelection = null){
$changes = array();
foreach($data as $k => $v)
$changes[] ='`'.$k.'` = "'.$v.'"';
if(!empty($conditionSelection)){
$where = array();
foreach($conditionSelection as $k => $v)
$where[]='`'.$k.'` = "'.$v.'"';
$where = 'WHERE '.implode(' AND ', $where);
}
else
$where = '';
//echo 'Запрос: '.'UPDATE `'.$name.'` SET '.implode(' , ', $changes).' '.$where;
return self::$storage->query('UPDATE `'.$name.'` SET '.implode(' , ', $changes).' '.$where);
}
}
?>

Описывать как реализована адаптация класса работы с MySQLi я не будут, попробуйте сами это сделать придерживаясь правилам адаптации и унификации.

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

<?php
class memento{
protected
$parent, // Объект, создавший хранителя и состояние которого хранителем сохраняется
$properties = array(); // Само состояние
public
function __construct(originator $parent){
$this->parent = $parent;
}
function setState(array $properties){
$this->properties = $properties;
return true;
}
function getState(originator $parent){
/* Возврат состояния только родителю.
Конечно это неправильная реализация хранителя, так как она нарушает
инкапсуляцию, но к сожалению PHP не обладает необходимыми для
реализации "хорошего" Хранителя конструкциями и возможностями */
if($this->parent = $parent)
return $this->properties;
else
return false;
}
}
?>

Хранитель работает с родителем, который должен наследоваться от следующего абстрактного класса:

<?php
abstract class originator{
protected
abstract function newMemento(); // Присмотритесь к этому методу

public
function setMemento(memento $m){
$prop = $m->getState($this);
foreach($prop as $k => $v)
$this->$k = $v;
}
function createMemento(){
$memento = $this->newMemento(); // А так же к этой строчке кода
$memento->setState(array_intersect_key(get_object_vars($this), get_class_vars(get_class($this))));
return $memento;
}
}
?>

Зачем мы использовали два метода newMemento и createMemento для одной задачи - создание и инициализация Хранителя? Просто здесь мы использовали шаблон "Фабричный метод" чтобы не приходилось в будущем дублировать код инициализации Хранителя. Если подробнее, то родитель сохраняет свое состояние в Хранителе при его создании методом createMemento. Этот метод выполняет функции фабрики и одновременно инициализирует Хранитель. Хранители могут быть разными (Простой Хранитель, Постоянный Хранитель), но методы инициализации всегда одинаковы, потому мы и отделили алгоритмы создающий Хранителя, от алгоритма его идентифицирующего использовав шаблон "Фабричный метод".
Решение задачи
И так последний класс - Постоянный Хранитель:

<?php
class constantMemento extends memento{
protected static $storage; // То самое унифицированное хранилище

public
function save(){
self::$storage->input(get_class($this->parent), $this->properties);
// Используем унифицированное Хранилище и не задумываемся о том, как оно работает
}

static function restore(originator $parent, array $identifier){
$result = self::$storage->select(get_class($parent), $identifier);
if($result){
$memento = new self($parent);
$memento->setState(array_pop($result));
return $memento;
}
return false;
}

function update(array $identifier){
return self::$storage->update(get_class($this->parent), $this->properties, $identifier);
}

function recover(array $identifier){
return self::$storage->remove(get_class($this->parent), $identifier);
}

static function setStorage(storage\storage\Istorage $storage){
// Шаблон "Стратегия" в этом классе позволяет менять используемое Хранилище "на лету"!
self::$storage = $storage;
}
}
?>

Наследование от memento позволяет нам отделить код простого Хранителя от возможностей Постоянного Хранителя. Так же наследование позволяет без изменения originator использовать Постоянного Хранителя для восстановление состояния Родителя (никакого дублирования кода). Обратите особое внимание на $storage. Переменная вынесена из экземпляра в классификатор (сделана static). Здесь мы придерживаемся принципов паттерна "Информационный эксперт" который говорит нам что методы (Хранилище это объект, который используется как унифицированный набор методов) должны быть вынесены в тот класс, который обладает необходимой информацией для метода. Класс Постоянного Хранителя обладает всеми необходимыми данными, а использование Хранилище для каждого экземпляра класса приводит к лишней работе и использованию дополнительной памяти (не обязательно конечно).
Результаты
Мы создали довольно простой класс "Постоянный Хранитель", который позволяет реализовать очень важную операцию - СОХРАНЕНИЕ ОБЪЕКТОВ В ЛЮБЫХ ПОСТОЯННЫХ ХРАНИЛИЩАХ! Если вам нужно будет сохранить состояние Пользователя в БД, Файле и в Сессии, то используя этот класс, у вас не возникнет никаких проблем и дублирования кода.
Конечно, класс не совершенен, так как не было времени на рефакторинг, но он выполняет свои задачи хорошо и достаточно гибок для расширения за счет использования делегирования, унификации, адаптации, интерфейсам, шаблонов проектирования.
Хотелось бы отметить, что эта статья не призвана учить ООП, она лишь показывает какие проблемы можно решать с помощью ОО подхода. Если вы что-то не поняли из статьи, ничего страшного, главное что вы увидели возможности и прелести ООП для решения проблем дублирования кода и упрощения реализации.
Удачи в постижении ОО подхода.
Автор: Артур (21.12.11 / 11:21)
Пример ООП, практика ООП, паттерны
Рейтинг: +1
Просмотры: 1817
Комментарии (7) »