Опубликован: 18.09.2006 | Уровень: специалист | Доступ: платный | ВУЗ: Московский государственный университет имени М.В.Ломоносова
Лекция 11:

Основные конструкции языков Java и C# (продолжение)

Элементы типов

Элементы или члены (members) пользовательских типов могут быть методами, полями (в классах) и вложенными типами. В классе можно также объявлять конструкторы, служащие для создания объектов этого класса. В обоих языках описание конструктора похоже на описание метода, только тип результата не указывается, а вместо имени метода используется имя самого класса.

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

У каждого элемента класса могут присутствовать модификаторы, определяющие доступность этого элемента из разных мест программы, а также его контекст — относится ли он к объектам этого класса (нестатический элемент) или к самому классу (статический элемент, помечается как static ).

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

Нестатические методы в обоих языках (а также свойства, индексированные свойства и события в C#) могут быть объявлены абстрактными (abstract), т.е. не задающими реализации соответствующей операции. Такие методы (а также свойства, индексированные свойства и события в C#) помечаются модификатором abstract. Вместо кода у абстрактного метода сразу после описания полной сигнатуры идет точка с запятой.

Методы ( свойства, индексированные свойства и события в C#), которые не должны быть перегружены в наследниках содержащего их класса, помечаются в Java как final, а в C#, как sealed.

В обоих языках можно использовать операции, реализованные на других языках. Для этого в C# используются стандартные механизмы .NET — класс реализуется на одном из языков, поддерживаемых .NET, с учетом ограничений на общие библиотеки этой среды, и становится доступен из любого другого кода на поддерживаемом .NET языке.

В Java для этого предусмотрен механизм Java Native Interface, JNI [6,7]. Класс Java может иметь ряд внешних методов, помеченных модификатором native. Вместо кода у таких методов сразу после описания полной сигнатуры идет точка с запятой. Они по определенным правилам реализуются в виде функций на языке C (или на другом языке, если можно в результате компиляции получить библиотеку с интерфейсом на C). Внешние методы, а также свойства, индексированные свойства, события и операторы, привязываемые по определенным правилам к функциям, которые имеют интерфейс на C и не вложены в среду .NET, есть и в C# — там такие операции помечаются как extern.

В Java, помимо перечисленных членов типов, имеются инициализаторы. Их описание приведено ниже.

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

В C# члены типов, помимо методов, полей, конструкторов и вложенных типов, могут быть константами, свойствами, индексированными свойствами, событиями или операторами. Кроме этого, в типе можно определить деструктор и статический конструктор.

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

Для многих из дополнительных разновидностей членов типов, имеющихся в C#, есть аналогичные идиомы в компонентной модели JavaBeans [8,9], предназначенной для построения элементов пользовательского интерфейса и широко используемой в рамках Java-технологий для создания компонентов, структуру которых можно анализировать динамически на основе предлагаемых JavaBeans соглашений об именовании методов. Далее вместе с примерами кода на C# в правом столбце в левом приводится аналогичный код, написанный в соответствии с JavaBeans.

Константы в Java принято оформлять в виде полей с модификаторами final static. Модификатор final для поля означает, что присвоить ему значение можно только один раз и сделать это нужно либо в статическом инициализаторе класса (см. ниже), если поле статическое, либо в каждом из конструкторов, если поле нестатическое:

Константы являются единожды вычисляемыми и неизменными далее значениями, хранящимися в классе или структуре.

Пример объявления константы приведен ниже.

public class A2
{
  public static final double PHI =
    1.61803398874989;
}
public class A2
{
  public const double Phi =
    1.61803398874989;
}

Компонентная модель JavaBeans определяет свойство (property) класса A, имеющее имя name и тип T, как набор из одного или двух методов, декларированных в классе AT getName() и void setName(T), называемых методами доступа (accessor methods) к свойству.

Свойство может быть доступным только для чтения, если имеется лишь метод get, и только для записи, если имеется лишь метод set.

Если свойство имеет логический тип, для метода чтения этого свойства используется имя isName().

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

Они и стали основанием для введения специальной конструкции для описания свойств в C#.

Свойства (properties) представляют собой "виртуальные" поля. Каждое свойство имеет один или оба метода доступа get и set, которые определяют действия, выполняемые при чтении и модификации этого свойства. Оба метода доступа описываются внутри декларации свойства. Метод set использует специальный идентификатор value для ссылки на устанавливаемое значение свойства.

Обращение к свойству — чтение (возможно, только если у него есть метод get ) или изменение значения свойства (возможно, только если у него есть метод set ) — происходит так же, как к полю.

При перегрузке свойства в наследниках перегружаются методы доступа к нему.

Пример объявления свойств и их использования приведен ниже.

public class MyArrayList
{
  private int[] items = new int[10];
  private int size = 0;

  public int getSize()
  {
    return size; 
  }

  public int getCapacity()
  {
    return items.Length; 
  }

  public void setCapacity(int value)
  {
    int[] newItems = new int[value];
    System.arraycopy
      (items, 0, newItems, 0, size);
    items = newItems;
  }

  public static void main(String[] args)
  {
    MyArrayList l = new MyArrayList();
    System.out.println(l.getSize());
    System.out.println(l.getCapacity());
    l.setCapacity(50);
    System.out.println(l.getSize());
    System.out.println(l.getCapacity());
  }
}
using System;

public class MyArrayList
{
  private int[] items = new int[10];
  private int size = 0;

  public int Size
  {
    get { return size; }
  }

  public int Capacity
  {
    get { return items.Length; }
    set 
    {
      int[] newItems = new int[value];
      Array.Copy
        (items, newItems, size);
      items = newItems;
    }
  }


  public static void Main()
  {
    MyArrayList l = new MyArrayList();
    Console.WriteLine( l.Size );
    Console.WriteLine( l.Capacity );
    l.Capacity = 50;
    Console.WriteLine( l.Size );
    Console.WriteLine( l.Capacity );
  }
}

JavaBeans определяет индексированное свойство (indexed property) класса A, имеющее имя name и тип T, как один или пару методов T getName(int) и void setName(int, T).

Свойства могут быть индексированы только одним целым числом. В дальнейшем предполагалось ослабить это ограничение и разрешить индексацию несколькими параметрами, которые могли бы иметь разные типы. Однако с 1997 года, когда появилась последняя версия спецификаций JavaBeans [9], этого пока сделано не было.

Индексированное свойство или индексер (indexer) — это свойство, зависящее от набора параметров.

В C# может быть определен только один индексер для типа и данного набора типов параметров. Т.е. нет возможности определять свойства с разными именами, но одинаковыми наборами индексов.

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

При перегрузке индексера в наследниках перегружаются методы доступа к нему.

Индексеры должны быть нестатическими.

Обращение к индексеру класса-предка в индексере наследника организуется с помощью конструкции base[…].

Пример декларации и использования индексера приведен ниже.

public class MyArrayList
{
  int[] items = new int[10];
  int size = 0;

  public int getItem(int i)
  {
    if (i < 0 || i >= 10) throw new 
       IllegalArgumentException();
    else return items[i]; 
  }
  

  public void setItem(int i, int value) 
  {
    if (i < 0 || i >= 10) throw new 
       IllegalArgumentException();
    else items[i] = value; 
  }


  public static void main(String[] args)
  {
    MyArrayList l = new MyArrayList();
    l.setItem(0, 23);
    l.setItem(1, 75);
    l.setItem(1, l.getItem(1)-1);
    l.setItem(0, 
        l.getItem(0) + l.getItem(1));
    System.out.println (l.getItem(0));
    System.out.println (l.getItem(1));
  }
}
using System;

public class MyArrayList
{
  int[] items = new int[10];
  int size = 0;

  public int this[int i]
  {
    get
    {
      if (i < 0 || i >= 10) throw new 
         IndexOutOfRangeException();
      else return items[i]; 
    }
    set 
    {
      if (i < 0 || i >= 10) throw new 
         IndexOutOfRangeException();
      else items[i] = value; 
    }
  }

  public static void Main()
  {
    MyArrayList l = new MyArrayList();
    l[0] = 23;
    l[1] = 75;
    l[0] += (--l[1]);
    Console.WriteLine(l[0]);
    Console.WriteLine(l[1]);
  }
}

События (events) в модели JavaBeans служат для оповещения набора объектов-наблюдателей (listeners) о некоторых изменениях в состоянии объекта-источника (source).

При этом класс EventType объектов, представляющих события определенного вида, должен наследовать java.util.EventObject. Все объекты-наблюдатели должны реализовывать один интерфейс EventListener, в котором должен быть метод обработки события (обычно называемый так же, как и событие ) с параметром типа EventType. Интерфейс EventListener должен наследовать интерфейсу java.util.EventListener.

Класс источника событий должен иметь методы для регистрации наблюдателей и их удаления из реестра. Эти методы должны иметь сигнатуры

public void addEventListener 
          (EventListener)
public void removeEventListener
          (EventListener).

Можно заметить, что такой способ реализации обработки событий воплощает образец проектирования "Подписчик".

В приведенном ниже примере все public классы и интерфейсы должны быть описаны в разных файлах.

Событие (event) представляет собой свойство специального вида, имеющее делегатный тип. У события, в отличие от обычного свойства, методы доступа называются add и remove и предназначены они для добавления или удаления обработчиков данного события, являющихся делегатами (это аналоги различных реализаций метода обработки события в интерфейсе наблюдателя в JavaBeans) при помощи операторов += и =.

Событие может быть реализовано как поле делегатного типа, помеченное модификатором event. В этом случае декларировать соответствующие методы add и remove необязательно — они автоматически реализуются при применении операторов += и = в к этому полю как к делегату.

Если же программист хочет реализовать какое-то специфическое хранение обработчиков события, он должен определить методы add и remove.

В приведенном ниже примере одно из событий реализовано как событие-поле, а другое — при помощи настоящего поля и методов add и remove, дающих совместно тот же результат.

При перегрузке события в наследниках перегружаются методы доступа к нему.

public class MouseEventArgs { ... }

public class MouseEventObject
  extends java.util.EventObject 
{ 
  MouseEventArgs args;
  
  MouseEventObject
  (Object source, MouseEventArgs args)
  {
    super(source);
    this.args = args;
  }
}

public interface MouseEventListener
  extends java.util.EventListener
{
  void mouseUp(MouseEventObject e);
  void mouseDown(MouseEventObject e);
}

import java.util.ArrayList;

public class MouseEventSource
{
  private ArrayList<MouseEventListener>
    listeners = new ArrayList
    <MouseEventListener >();



  public synchronized void 
  addMouseEventListener
    (MouseEventListener l)
  { listeners.add(l); }
  
  public synchronized void
  removeMouseEventListener 
    (MouseEventListener l)
  { listeners.remove(l); }
  


 protected void notifyMouseUp
    (MouseEventArgs a)
  {
    MouseEventObject e =
      new MouseEventObject(this, a);
    ArrayList<MouseEventListener> l;
    synchronized(this)
    {
      l =
      (ArrayList<MouseEventListener>)
        listeners.clone();
      for(MouseEventListener el : l)
        el.mouseUp(e);
    }
  }
  
  protected void notifyMouseDown
    (MouseEventArgs a)
  {
    MouseEventObject e =
      new MouseEventObject(this, a);
  
    ArrayList<MouseEventListener> l;
    synchronized(this)
    {
      l =
       (ArrayList<MouseEventListener>)
        listeners.clone();
      for(MouseEventListener el : l)
        el.mouseDown(e);
    }
  }
}

public class HandlerConfigurator
{
  MouseEventSource s = 
    new MouseEventSource();
  
  MouseEventListener listener =
    new MouseEventListener()
  {
    public void mouseUp
     (MouseEventObject e) { ... }
    public void mouseDown
     (MouseEventObject e) { ... }
  };

  public void configure()
  {
    s.addMouseEventListener(listener);
  }
}
public class MouseEventArgs { ... }

public delegate void MouseEventHandler
  (object source, MouseEventArgs e);

public class MouseEventSource
{
  public event MouseEventHandler MouseUp;

  private MouseEventHandler mouseDown;
  public event 
    MouseEventHandler MouseDown
  {
    add
    {
      lock(this)
      { mouseDown += value; }
    }
    remove 
    { 
      lock(this)
      { mouseDown -= value; }
    }
  }

  protected void 
    OnMouseUp(MouseEventArgs e)
  {
    MouseUp(this, e);
  }
  protected void 
    OnMouseDown(MouseEventArgs e)
  {
    mouseDown(this, e);
  }
}

public class HandlerConfigurator
{
  MouseEventSource s =
    new MouseEventSource();

  public void UpHandler
    (object source, MouseEventArgs e)
  { ... }
  public void DownHandler
    (object source, MouseEventArgs e)
  { ... }



  public void Configure()
  {
    s.MouseUp += UpHandler;
    s.MouseDown += DownHandler;
  }
}

Методы доступа к свойствам, индексерам или событиям в классах-наследниках могут перегружаться по отдельности, т.е., например, метод чтения свойства перегружается, а метод записи — нет.

В C# 2.0 введена возможность декларации различной доступности у таких методов. Например, метод чтения свойства можно сделать общедоступным, а метод записи — доступным только для наследников.

Для этого можно описать свойство так:

public int Property
{
  get { … }
  protected set { … }
}

В Java никакие операторы переопределить нельзя.

Вообще, в этом языке имеются только операторы, действующие на значениях примитивных типах, сравнение объектов на равенство и неравенство, а также оператор + для строк (это объекты класса java.lang.String), обозначающий операцию конкатенации.

Оператор + может применяться и к другим типам аргументов, если один из них имеет тип String. При этом результатом соответствующей операции является конкатенация его и результата применения метода toString() к другому операнду в порядке следования операндов.

Некоторые операторы в C# можно переопределить (перекрыть) для данного пользовательского типа. Переопределяемый оператор всегда имеет модификатор static.

Переопределяемые унарные операторы (их единственный параметр должен иметь тот тип, в рамках которого они переопределяются, или объемлющий тип ): +, -, !, ~ (в качестве типа результата могут иметь любой тип), ++,-- (тип их результата может быть только подтипом объемлющего), true, false (тип результата bool ).

Переопределяемые бинарные операторы (хотя бы один из их параметров должен иметь объемлющий тип, а возвращать они могут результат любого типа): +, -, *, /, %, &, |, ^, <<, >>, ==, !=, <, >, <=, >=. Для операторов сдвига << и >> ограничения более жесткие — первый их параметр должен иметь объемлющий тип, а второй быть типа int.

Можно определять также операторы приведения к другому типу или приведения из другого типа, причем можно объявить такое приведение неявным с помощью модификатора implicit, чтобы компилятор сам вставлял его там, где оно необходимо для соблюдения правил соответствия типов. Иначе надо использовать модификатор explicit и всегда явно приводить один тип к другому.

Некоторые операторы можно определять только парами — таковы true и false, == и !=, < и >, <= и >=.

Операторы true и false служат для неявного преобразования объектов данного типа к соответствующим логическим значениям.

Если в типе T определяются операторы & и |, возвращающие значение этого же типа, а также операторы true и false, то к объектам типа можно применять условные логические операторы && и ||. Их поведение в этом случае может быть описано соотношениями (x && y) = (T.false(x)? x : (x & y)) и (x || y) = (T.true(x)? x : (x | y)).

Аналогичным образом автоматически переопределяются составные операторы присваивания, если переопределены операторы +, -, *, /, %, &, |, ^, << или >>.

Ниже приведен пример переопределения и использования операторов. Обратите внимание, что оператор приведения типа MyInt в int действует неявно, а для обратного перехода требуется явное приведение.

Другая тонкость — необходимость приведения объектов типа MyInt к object в методе AreEqual — если этого не сделать, то при обращении к оператору == возникнет бесконечный цикл, поскольку сравнение i1 == null тоже будет интерпретироваться как вызов этого оператора.

using System;

public class MyInt
{
  int n = 0;

  public MyInt(int n) { this.n = n; }

  public override bool Equals(object obj)
  {
    MyInt o = obj as MyInt;
    if (o == null) return false;
    return o.n == n;
  }

  public override int GetHashCode()
  { return n; }

  public override string ToString()
  { return n.ToString(); }

  public static bool AreEqual
    (MyInt i1, MyInt i2)
  {
    if ((object)i1 == null)
      return ((object)i2 == null);
    else return i1.Equals(i2);
  }

  public static bool operator ==
    (MyInt i1, MyInt i2)
  { return AreEqual(i1, i2); }

  public static bool operator !=
    (MyInt i1, MyInt i2)
  { return !AreEqual(i1, i2); }

  public static bool operator true
    (MyInt i) 
  { return i.n > 0; }

  public static bool operator false
    (MyInt i)
  { return i.n <= 0; }

  public static MyInt operator ++ 
    (MyInt i)
  { return new MyInt(i.n + 1); }

  public static MyInt operator -- 
    (MyInt i)
  { return new MyInt(i.n - 1); }

  public static MyInt operator &
    (MyInt i1, MyInt i2)
  { return new MyInt(i1.n & i2.n); }

  public static MyInt operator |
    (MyInt i1, MyInt i2)
  { return new MyInt(i1.n | i2.n); }

  public static implicit operator int
    (MyInt i)
  { return i.n; }

  public static explicit operator 
    MyInt (int i)
  { return new MyInt(i); }

  public static void Main()
  {
    MyInt n = (MyInt)5;
    MyInt k = (MyInt)(n - 3 * n);
    Console.WriteLine("k = " + k + 
      " , n = " + n);
    Console.WriteLine("n == n : " + 
      (n == n));
    Console.WriteLine("n == k : " + 
      (n == k));
    Console.WriteLine(
      "(++k) && (n++) : " + 
      ((++k) && (n++)));
    Console.WriteLine("k = " + k + 
      " , n = " + n);
    Console.WriteLine(
      "(++n) && (k++) : " +
       ((++n) && (k++)));
    Console.WriteLine("k = " + k + 
      " , n = " + n);
  }
}

Аналогом деструктора в Java является метод protected void finalize(), который можно перегрузить для данного класса.

Так же, как и деструктор в C#, этот метод вызывается на некотором шаге уничтожения объекта после того, как тот был помечен сборщиком мусора как неиспользуемый.

Деструктор предназначен для освобождения каких-либо ресурсов, связанных с объектом и не освобождаемых автоматически средой .NET, либо для оптимизации использования ресурсов за счет их явного освобождения.

Деструктор вызывается автоматически при уничтожении объекта в ходе работы механизма управления памятью .NET. В этот момент объект уже должен быть помечен сборщиком мусора как неиспользуемый.

Деструктор оформляется как особый метод, без возвращаемого значения и с именем, получающимся добавлением префикса ‘ ~ ’ к имени класса

public class MyFileReader
{
  java.io.FileReader input;

  public MyFileReader(String path)
    throws FileNotFoundException
  {
    input = new java.io.FileReader
     (new java.io.File(path));
  }

  protected void finalize()
  {
    System.out.println("Destructor");
    try { input.close(); }
    catch (IOException e)
    {
      e.printStackTrace();
    }
  }
}
using System;

public class MyFileStream
{
  System.IO.FileStream input;

  public MyFileStream(string path)
  {
    input = System.IO.File.Open
      (path, System.IO.FileMode.Open);
  }


  ~MyFileStream()
  {
    Console.WriteLine("Destructor");
    input.Close();
  }
}

Инициализаторы представляют собой блоки кода, заключенные в фигурные скобки и расположенные непосредственно внутри декларации класса.

Эти блоки выполняются вместе с инициализаторами отдельных полей — выражениями, которые написаны после знака = в объявлениях полей — при построении объекта данного класса, в порядке их расположения в декларации.

Статические инициализаторы — такие же блоки, помеченные модификатором static — выполняются вместе с инициализаторами статических полей по тем же правилам в момент первой загрузки класса в Java-машину.

Статический конструктор класса представляет собой блок кода, выполняемый при первой загрузке класса в среду .NET, т.е. в момент первого использования этого класса в программе. Это аналог статического инициализатора в Java.

Оформляется он как конструктор с модификатором static.

public class A
{
  static 
  {
    System.out.println("Loading A");
  }
  
  static int x = 1;
  
  static
  {
    System.out.println("x = " + x);
    x++;
  }

  static int y = 2;
  
  static
  {
    y = x + 3;
    System.out.println("x = " + x);
    System.out.println("y = " + y);
  }
  
  public static void main(String[] args)
  {}
}
using System;

public class A
{
  static A() 
  {
    Console.WriteLine("Loading A");
    Console.WriteLine("x = " + x);
    x++;
    y = x + 3;
    Console.WriteLine("x = " + x);
    Console.WriteLine("y = " + y);
  }
  
  static int x = 1;
  
  static int y = 2;
  
  public static void Main() {}
}

Приведенный выше код выдает результат

Loading A
x = 1
x = 2
y = 5

Приведенный выше код выдает результат

Loading A
x = 1
x = 2
y = 5

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

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

Получить этот объект внутри декларации вложенного типа можно с помощью конструкции ClassName.this, где ClassName — имя объемлющего типа.

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

В C# модификатор static у класса, все равно, вложенного в другой или нет, обозначает, что этот класс является контейнером набора констант и статических операций. Все его элементы должны быть декларированы как static.

public class ContainingClass
{
  static int counter = 1;
  static int ecounter = 1;
  int id = counter++;
  
  class EmbeddedClass
  {
    int eid = ecounter++;
    
    public String toString()
    {
      return "" +
        ContainingClass.this.id +
        '.' + eid;
    }
  }
  
  public String toString()
  {
    return "" + id;
  }
  
  public static void main
    (String[] args)
  {
    ContainingClass 
       c  = new ContainingClass()
     , c1 = new ContainingClass();
    System.out.println(c);
    System.out.println(c1);
    
    EmbeddedClass 
        e  = c.new EmbeddedClass()
      , e1 = c.new EmbeddedClass()
      , e2 = c1.new EmbeddedClass();
    System.out.println(e);
    System.out.println(e1);
    System.out.println(e2);
  }
}

В C# класс может определить различные реализации для операций ( методов, свойств, индексеров, событий ) с одинаковой сигнатурой, если они декларированы в различных реализуемых классом интерфейсах.

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

using System;

public interface I1
{
  void m();
}

public interface I2
{
  void m();
}

public class A : I1, I2
{
  public void m()
  {
    Console.WriteLine("A.m() called");
  }

  void I1.m()
  {
    Console.WriteLine
      ("I1.m() defined in A called");
  }

  void I2.m()
  {
    Console.WriteLine
     ("I2.m() defined in A called");
  }

  public static void Main()
  {
    A f = new A();
    I1 i1 = f;
    I2 i2 = f;

    f.m();
    i1.m();
    i2.m();
  }
}

Результат работы приведенного выше примера следующий.

A.m() called
I1.m() defined in A called
I2.m() defined in A called

Последовательность выполнения инициализаторов полей и конструкторов классов-предков и наследников при построении объектов в Java и C# различается достаточно сильно.

Владислав Нагорный
Владислав Нагорный

Подскажите, пожалуйста, планируете ли вы возобновление программ высшего образования? Если да, есть ли какие-то примерные сроки?

Спасибо!

Лариса Парфенова
Лариса Парфенова

1) Можно ли экстерном получить второе высшее образование "Программная инженерия" ?

2) Трудоустраиваете ли Вы выпускников?

3) Можно ли с Вашим дипломом поступить в аспирантуру?

 

Денис Сафронов
Денис Сафронов
Россия
жылдыз насырбекова
жылдыз насырбекова
Киргизия, бишкек