Усовершенствование паттерна Flyweight в биовычислениях

в 14:19, , рубрики: биоинформатика, паттерны проектирования, Проектирование и рефакторинг, Совершенный код, метки: ,

Предыстория

Сразу извиняюсь за сложность, но сложна как сама ситуация для применения этого, так и способ решения, но получается в результате красиво и эффективно :)

Началось с того, что описал одну проблемку о проблемах ООП. Потом случайно благодаря разговорам тут начал обдумывать паттерны проектирования. И в связи с темой «полное копирование объекта» вышел на паттерн Flyweight. Кто не знает — прошу вначале читайте о нем в Приемы объектно-ориентированного проектирования. Паттерны проектирования (Не в вики, а в оригинале).

Основная идея там такова:

Паттерн Flyweight описывает, как совместно разделять очень мелкие объекты без чрезмерно высоких издержек. Каждый объект-приспособленец имеет две части: внутреннее и внешнее состояния. Внутреннее состояние хранится (разделяется) в приспособленце и состоит из информации, не зависящей от его контекста. Внешнее состояние хранится или вычисляется объектами-клиентами и передается приспособленцу при вызове его методов.

Задача

Мы рассмотрим как это улучшить на конкретном примере. О биовычислениях буду говорить очень мало — но пример будет построен на этом. Суть биовычислений попытаюсь полностью вытравить, оставив только схему.

P.S. Если кому то интересна проблематика самих биовычислений по задаче сворачивания РНК/белков — делайте заказ напишу тогда отдельную статью.

Итак есть объект РНК (RNA). Он является наследником более общего объекта Chain (Цепь из молекул) (например, могут быть еще ДНК, белки и т.д.). Каждое РНК состоит из множества молекул (нуклеотидов) (Molecule). В данном случае молекула может быть четырех видов (наследники Molecule) — Cytosine, Uracil, Guanine, Adenine. Каждая молекула в зависимости от типа состоит из 28-33 атомов (Atom). У каждого атома есть три расчетных угла

public class Angles 
{
        private float phi;
        private float theta;
        private float d;
}
 

Требуется на основании первичной структуры цепочки, например, aagaggucggcaccugacgu — построить трехмерную модель. Т.е. создать порядка 1000 взаимосвязанных атомов, которые в общем виде представляют собой граф. Создание этого графа занимает серьезное время.

Дальше нужно выполнять биорасчеты. Эта цепочка принимает определенную 3D конфигурацию. Рассчитываются координаты каждого атома и его углы по отношению к другому атому. Расчеты проходят по схеме — берем последние «хорошие» состояние РНК и пробуем его «улучшить». Делаем скажем 1000 попыток повернуть одну молекулу по одному углу. Из этой 1000 выбирается только один самый лучший вариант — фиксируется (становится «хорошим» состоянием) и вычисления повторяются.

Решение в лоб

Собственно так оно и решалось вначале, было унаследовано из кода по проекту Rosseta@home.

Как видим, чтобы провести 1000 попыток повернуть нужно копировать начальное состояние. Т.е. есть объекты

RNA BestRNA;
RNA CurrentRNA;

И нужно делать

CurrentRNA = BestRNA.Clone();
Calculate(CurrentRNA);
 

Проблема именно в этом Clone(). Помним, что построить граф всех атомов очень ресурсоемкая процедура.
А если не строить, то затрем углы всех атомов в «хорошем» состоянии.

Классический Flyweight

Классический Flyweight предлагает нам вытянуть свойства углы из атомов и поместить их в объекты выше. Вплоть до того, что в массив, который будет располагаться в объекте RNA. Массив этот будет как то индексироваться, чтобы по индексу можно было однозначно попасть в нужный атом.

Тогда при клонировании нужно будет клонировать только этот массив, а граф атомов не нужно.

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

Что делать?

Главное вначале осознать, что мы на самом деле делаем расчеты во времени. Т.е. нам нужно сохранять траекторию атомов с течением времени. Объектная картина тогда восстанавливается.

Дальше будет много кода с минимум комментариев, что не понятно спрашивайте.

Итак, первое что делаем заменяем одиночные углы, на массивы времени, но с доступом по текущему времени.

public class Angles
{
        private float[] phi = new float[Chain.TimeHistory];
        private float[] theta = new float[Chain.TimeHistory];
        private float[] d = new float[Chain.TimeHistory];
        public float Phi
        {
            get { return phi[Chain.Time]; }
            set { phi[Chain.Time] = value; }
        }
        public float Theta
        {
            get { return theta[Chain.Time]; }
            set { theta[Chain.Time] = value; }
        }
        public float D
        {
            get { return d[Chain.Time]; }
            set { d[Chain.Time] = value; }
        }
        public void SetInitial(int argTime)
        {
            Phi = phi[argTime];
            Theta = theta[argTime];
            D = d[argTime];
        }
}

Далее время контролируется на уровне цепи, т.е. самого верхнего объекта. Даю кусок кода и постепенно поясняю. Вся идея будет ясна только в конце (изложение как в математике — вначале не понятно зачем нужно, и только в конце ясно зачем все это).

public class Chain
{
        private static Chain instance;
        public Chain(int MolCount)
        {
            instance = this;
        }
        /// Текущие время
        private int time = 0;
        public static int Time
        {
            get { return instance.time; }
            set { instance.time = value; }
        }
        public static int OldTime
        {
            get 
            {
                int oldTime = instance.time - 1;
                if (oldTime == -1)
                {
                    oldTime = TimeHistory - 1;
                }
                return oldTime; 
            }
        }
        /// Число шагов времени, которые хранит объект
        public static int TimeHistory = 5;
 
        private static int generation;
        public static int Generation
        {
            get { return generation; }
        }
        /// Перевод времени на один шаг вперед
        protected void NextTime(int argGeneration)
        {
            Time++;
            if (Time >= TimeHistory)
            { 
                Time = 0;
                generation = argGeneration + 1;
                CurrentMaxTimeID = 0;
            }
        }
 
        public void CheckTime(int argTimeID, int argGeneration)
        {
            // Не может быть поколения больше чем на 2 раза старше, и если это текущие поколение, 
            // то штамп времени не может быть больше, чем выданные штампы 
            // (такое может быть только между текущим и прошлым поколением)
            // и не может быть поколение больше текущего
            if (argGeneration < (generation - 1) || 
               (argGeneration == generation && CurrentMaxTimeID < argTimeID) || 
               argGeneration>generation)
            {
                Console.WriteLine("ErrorGenerationRNA");
                Console.ReadLine();
            }
            if (Time != argTimeID)
            {
                Time = argTimeID;
            }
        }
 
        private static int CurrentMaxTimeID = 0;
        public static int GetNextTimeID(int argCurrentTimeID, int argGeneration)
        {
            int NextTime = -1;
 
            if (argGeneration == generation)
            {
                NextTime = argCurrentTimeID + 1;
            }
            if (argGeneration == generation - 1)
            {
                NextTime = 0;
            }
            if (CurrentMaxTimeID < NextTime)
            {
                CurrentMaxTimeID = NextTime;
            }
            return NextTime;
        }
}

Chain выполняет паттерн Singleton, но не совсем классически. Во первых, когда создается первый объект RNA (наследник Chain), через определенный конструктор — ссылка на instance заменяется. Т.е. это не совсем единственный объект вообще, а последний созданный. И собственно, ссылка на объект не предоставляется. Получить можно только текущие время Time. Таким образом, «старые объекты» от RNA могут еще существовать, но не во времени.

Массивы создаются на величину TimeHistory. Это относительная величина, число шагов в траектории состояний, которые важно иметь ОДНОВРЕМЕННО. В нашем пример могла быть вообще два: Best и Current. Но для оптимальности чуть увеличено до 5.

Далее, что делается. Нужно полностью контролировать доступ к свойствам/методам RNA, чтобы заставить его работать во времени. Используем паттерн «Посредник» (но так что использующие класс не понимает это — т.е. прозрачно)

Класс RNA переименовываем в RNARealise. А вместо реального RNA создаем «посредника»:

    public class RNA
    {
        private RNARealise body;
 
        private int TimeID = 0;
        private int GenerationID = 0;
 
        public Molecule[] Molecules
        {
            get 
            {
                body.CheckTime(TimeID, GenerationID);
                return body.Molecules; 
            }
        }
        public RNA(RNASeq argSeq)
        {
            body = new RNARealise(argSeq);
        }
        public RNA Clone()
        {
            body.CheckTime(TimeID, GenerationID);
            RNA NewRNA = (RNA)this.MemberwiseClone();
            NewRNA.body = NewRNA.body.Clone(this.GenerationID);
            NewRNA.TimeID = Chain.GetNextTimeID(this.TimeIDthis.GenerationID);
            NewRNA.GenerationID = Chain.Generation;
 
            return NewRNA;
        }
 
        public void Refold(int argPosition)
        {
            body.CheckTime(TimeID, GenerationID);
            body.Refold(argPosition);
        }
}

Что тут важно. В объект добавляется идентификатор — штамп времени TimeID и поколение объектов GenerationID. А перед каждым доступом к свойству и перед каждым вызовом времени проверяем в том ли времени находится объект body.CheckTime(TimeID, GenerationID).

И рассмотрим как эммулируется клонирование public RNA Clone().

в реальности клонируется только оболочка посредника, в которой присваивается новый штамп времени и поколения. Внутри будет еще перевод времени клона NextTime(argGeneration). А на уровне клонирования молекулы будет еще инициализация углов предыдущим значением

for (int i = 1; i < FullAngles.Length; i++)
{
    FullAngles[i].SetInitial(Chain.OldTime);
}
 

Что существенно меньше чем полное клонирование. А весь вызывающий код не меняется вообще — там как делалось клонирование, так и делается. Расчет имел примерно такую схему

public void BlockFolding()
{
    RNA saveRNA = CurrentRNA.Clone();
    locScore = NucFluctuation(saveRNA);
    // сохранение найденого лучшего состояния
    save();
}
 
public double NucFluctuation(RNA argRNA)
{
    for (int j = 1; j < FragmentCount; j++)
    {
        RNA locRNA = argRNA.Clone();
        // Осуществление поворота
        Rotate.AtomAngleRotate(locRNA);
        // Расчет выгодности поворота
        RNAScore.Score(locRNA);
    }
}
 

Попробуете догадаться как происходит так, что углы в разное время не путаются, хотя непосредственно временем сверху никто не управляет? (вообщем оставлю это на «домашние задание» читателю, если все еще сложно понять — пойму по комментариям).

Автор: tac


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js