Написать пост

Как автоматически проверить задание на знание ООП на примере Stepik

Логотип компании Иннотех

Рассказали, как построить автоматизированную систему для проверки задач на знание ООП согласно требованиям Stepik.

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

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

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

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

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

Описание стандартной схемы проверки

Механизм проверки заданий на программирование на платформе Stepik состоит из двух частей:

  1. «Языки и шаблоны»;
  2. «Расширенный редактор».

В расширенном редакторе автор задания должен реализовать три функции:

  1. generate();
  2. check(reply, clue);
  3. solve(dataset).

generate возвращает список строк.

Каждая строка — это один тест, который проверяет код учащегося независимо от других. Текст строки попадает в поток ввода в начале теста.

solve(dataset) — функция, реализующая эталонное решение. В качестве аргумента dataset она получает строку теста целиком, с символами переноса строк. В качестве ответа функция должна предоставить строку эталонного ответа.

check(reply, clue) — функция, осуществляющая сравнение эталонного ответа и ответа учащегося. На вход принимает две строки. Возвращает логическое значение (True или False) в зависимости от результата сравнения. При получении на вход в качестве обоих параметров эталонного ответа должна возвращать True, иначе задача считается сломанной (то есть некорректно оценит ответ учащегося).

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

Например, если в задаче требуется найти площадь правильного треугольника со стороной 5, то некорректно в качестве эталонного значения использовать строку «10.825317547305483» — в зависимости от округления и порядка операций точность вычислений может быть разной. Более корректно будет привести полученный от учащегося и от эталонной функции ответ к типу float, после чего сравнить абсолютную разницу между этими числами с пороговым значением (например, 0,0000000001).

В блоке «Языки и шаблоны» фактически формируется код учащегося с помощью четырёх блоков в следующем порядке:

			::python3
::kotlin
::header 
  # Код, выполняющийся до кода учащегося
::code 
  # Код, который будет показан студенту и может быть им изменён
::footer 
  # Код, выполняющийся после кода студента
		

В данном примере python3 и kotlin — языки программирования, разрешённые к использованию учащимся. Здесь могут быть перечислены несколько языков, каждый с новой строки.

Как автоматически проверить задание на знание ООП на примере Stepik 1

Такая реализация позволяет довольно легко создавать задания, где учащийся сам должен считать что-то из потока ввода, а ответ выводить в поток вывода (есть даже упрощённый редактор во вкладке «Тестовые данные», где задаются непосредственно строки ввода и вывода).

Какие у этого сложности

Такая схема не проверяет, как был получен ответ.

Чтобы обойти это, необходимо:

  • сгенерировать уникальные тесты, 
  • считать их в блоке ::header до кода студента, 
  • вывести в блоке ::footer код, который в зависимости от состояния теста, полученного ранее, проведёт только определённые проверки.

Например, для Python: сначала проверить есть ли в пространстве local объект с именем класса, чтобы узнать, реализовал ли учащийся такой класс. После чего узнать тип этого объекта, чтобы удостовериться, что это именно класс, а не функция. И, наконец, вызвать конструктор этого класса, чтобы удостовериться, что он работает.

И только если весь этот код отработает без ошибок, можно вывести какое-то сообщение, например, «Базовая проверка пройдена».

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

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

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

Рассмотрим этот процесс на примере грейдера задачи, проверяющей реализацию на Python простого класса Heap, хранящего внутри экземпляров всего один атрибут data с типом список.

Система проверки задачи

generate

Создадим шесть простых сообщений, которые будут публичными тестами.

Остальные тесты будут приватными (для начала добавим один). Для определённости и простоты отладки поместим внутрь тестовых сообщений копию кода, который будем выполнять в тестовом сценарии.

			def generate():
  return ['Класс "Heap" существует?\n',
      'Создадим пустой экземпляр класса\nh = Heap()\n',
      'Создадим экземпляр с данными\nsome_heap = Heap([777, 42, 55, 11, 1,
  2, 3, 4, 5, 6, 7, 8, 9])\n',
      'Ещё пара проверок, например, что если создать два экземпляра, и в одном из них
  что-то поменять?\nh1 = Heap([42])\nh2 = Heap([1, 2, 3, 4])\nh1.data[0] =
  100500\n',
      'А что будет если создать два экземпляра без передачи данных, а потом изменить data в одном из них? 2 точно
  не изменится?\nh1 = Heap()\nh1.data.append(1)\nh2 = Heap()\nprint(h1.data,
  h2.data)\n',
      'Документацию не забыли?\n',
      'h1 = Heap([1, 2, 3, 4, 5, 6, 7, 8, 9])\nh2 = 
Heap()\nh2.data.append(666)']
		

check

Как уже было описано ранее, сравнение с эталоном в данном уроке можно проводить простым сравнением строк:

			def check(reply, clue):
  return reply.strip() == clue.strip()
		

solve

Внутрь функции solve поместим эталонный класс Heap, который должен проходить все проверки.

В дальнейшем весь код мы переместим в раздел ::footer шаблона с двумя изменениями:

  1. Шаблон не будет содержать эталонной реализации Heap, её должен написать учащийся.
  2. Вместо return мы будем использовать print, так как по жизненному циклу код учащегося не возвращает строку, а именно выводить её в поток вывода.

Система проверки цепочек задач с постепенно усложняющимся условием

Очевидно, это необходимо для того, чтобы путём декомпозиции задачи позволить учащемуся сперва решить более простую задачу. Например, реализовать класс-заглушку, как в примере выше, и только после этого добавить в него методы, соответствующие реальной структуре данных (в нашем примере, очевидно, «куче»).

Система проверки всех задач строится по описанному выше принципу, однако наследует все тесты предыдущих задач, так как мы модифицируем и расширяем функционал одних и тех же классов.

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

Так же необходимо заблокировать возможность использования модулей стандартной библиотеки Python, реализующих функциональность «кучи», чтобы учащийся самостоятельно реализовал их. Покажем это на примере блокировки модуля heapq:

			import sys
sys.modules['heapq'] = None
		

Код помещается в начале шаблона, (в блоке ::header) и выполняется до кода учащегося.

Что дальше?

Примеры описанного подхода можно посмотреть в следующих уроках:

  1. на примере класса «Кучи»;
  2. на примере животных (кстати, в этом курсе такой же подход во многих уроках используется для задач на написание функций).

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

Следите за новыми постами
Следите за новыми постами по любимым темам
1К открытий2К показов