Пересказ моего GitHub.io пост._
Вступление
Самый крупный личный проект, которым я сейчас занимаюсь, – это эмулятор ( Emu Roxy ). Главная цель состояла в том, чтобы создать более крупный продукт, который я полностью контролирую и могу экспериментировать с инструментами и методами, чтобы стать лучшим программистом. Подцели включают в себя изучение чего-то о машинах, на которых я вырос, и, возможно, возможно, дойду до того, что я написал свой собственный ретро-эмулятор NES. В долгосрочной перспективе цель pie in the sky состояла в том, чтобы создать хорошо продуманный, хорошо протестированный, доступный, подключаемый мульти-эмулятор с открытым исходным кодом.
Однако в первую очередь я хотел сосредоточиться на том, что, по моему мнению, важно в хорошей Java-программе:
- Тщательно проверенный
- Читаемый
- Эффективный
- Ремонтопригодный/Расширяемый
Удовлетворяя всему вышесказанному, ИМХО, я бы сказал, что в целом он был хорошо разработан.
Итак, я начал с наиболее документированной части NES. Процессор MOS 6502 . О чем я хочу поговорить сегодня, так это о том, как я эмулировал коды операций и как мой подход дал мне хороший путь к обновлению, когда я понял, что моя реализация не так хороша, как могла бы быть. В качестве примечания я расскажу, почему TDD – если вы правильно его понимаете и используете – очень полезный метод в разработке Java.
Подход № 1: Результат разработки, основанной на тестировании (TDD)
Один из подходов, с которым я играл, – это TDD. Где я пишу неудачный тест, затем пишу код, чтобы заставить его работать, и итеративно создаю полностью протестированный модульный продукт. Это привело меня к написанию минимального объема кода, чтобы тесты работали. Результатом был отличный процесс и… это:
public void step() { log.debug("STEP >>>"); final OpCode opCode = OpCode.from(nextProgramByte().getRawValue()); //Execute the opcode log.debug("Instruction: {}...", opCode.getOpCodeName()); switch (opCode){ case ASL_A: withRegister(Register.ACCUMULATOR, this::performASL); break; case ASL_Z: withByteAt(RoxWord.from(nextProgramByte()), this::performASL); break; case ASL_Z_IX: withByteXIndexedAt(RoxWord.from(nextProgramByte()), this::performASL); break; case ASL_ABS_IX: withByteXIndexedAt(nextProgramWord(), this::performASL); break; case ASL_ABS: withByteAt(nextProgramWord(), this::performASL); break; case LSR_A: withRegister(Register.ACCUMULATOR, this::performLSR); break; case LSR_Z: withByteAt(RoxWord.from(nextProgramByte()), this::performLSR); break; case LSR_Z_IX: withByteXIndexedAt(RoxWord.from(nextProgramByte()), this::performLSR); break; case LSR_ABS: withByteAt(nextProgramWord(), this::performLSR); break; case LSR_ABS_IX: withByteXIndexedAt(nextProgramWord(), this::performLSR); break; ...
Это, конечно, усечено, так как на 6502 гораздо больше кодов операций. Соответствует ли это моим критериям для хорошо разработанной Java-программы?
Хорошо протестировано : Покрытие линии составляет около 98-99%. Статический анализ. Мутация проверена на 98%
Читаемый : Частично. У каждого кода операции есть путь. Инструкции по его выполнению даны на английском языке. Немного раздражает, что вам нужно так много прокручивать, а сложные инструкции в конечном итоге оказываются запутанными для вложенных вызовов методов, чтобы они читались плавно.
Эффективный : Для большого числа случаев операторы переключения довольно эффективны.
Ремонтопригодный : Нет. Существует множество методов, позволяющих уменьшить дублирование, которые в конечном итоге сами становятся дублированием. Части логики не разделимы, как адресация и операции.
Расширяемый : Давайте посмотрим…
Поэтому я хотел посмотреть, смогу ли я добиться большего.
Наследие и красота TDD
Это привело к довольно сложному в обслуживании коду, но правильному; Благодаря нашим тестам, быстро и точно проверяемому правильному. Это означает, что мы можем возиться с другими реализациями, и огромное пространство для тестирования теоретически позволит выявить любые ошибки за очень короткий цикл разработки.
Подход № 2: Функциональный Java-код
Я хочу свести к минимуму дублирование, написав код один раз для каждой операции и один раз для каждого режима адресации. Поскольку мы уже ссылаемся на коды операций по перечислению, я подумал, можно ли проанализировать значение кода операции ( 0x0A
) в его перечисление ( ASL_A
), а затем просто вызвать выполнить ()
метод в этом экземпляре перечисления, который запускает подключенный лямбда-код, предоставляющий среду (память, регистры и alu). Поскольку каждое перечисление кода операции знает свой собственный режим адресации, оно может вызывать address()
в этом режиме адресации (который также вызывает подключенную лямбду), предоставляя нам все необходимое для создания составных инструкций.
Обзор
Таким образом, myenum становится определением, содержащим байтовое значение, его режим адресации и выполняемую операцию. Таким образом, код операции является пересечением режима адресации и операции
ASL_A(0x0A, AddressingMode.ACCUMULATOR, Operation.ASL);
Аргумент операции – это операция над средой (a: накопитель, r: регистры, m: память), заданное адресное значение (v):
public enum Operation implements AddressedValueInstruction { /** Shift all bits in byte left by one place, setting flags based on the result */ ASL((a,r,m,v) -> { final RoxByte newValue = a.asl(v); r.setFlagsBasedOn(newValue); return newValue; }), ... @Override public RoxByte perform(Mos6502Alu alu, Registers registers, Memory memory, RoxByte value) { return instruction.perform(alu, registers, memory, value); }
Режим адресации использует среду (r: регистры, m: память, a: накопитель) для адресации значения, выполняет над ним заданную операцию (i) и помещает возвращаемое значение обратно в адресное местоположение:
public enum AddressingMode implements Addressable { /** Expects no argument, operation will be performed using the Accumulator Register*/ ACCUMULATOR("Accumulator", 1, (r, m, a, i) -> { final RoxByte value = r.getRegister(Registers.Register.ACCUMULATOR); r.setRegister(Registers.Register.ACCUMULATOR, i.perform(a, r, m, value)); }), ... @Override public void address(Registers r, Memory m, Mos6502Alu alu, AddressedValueInstruction instruction) { address.address(r, m, alu, instruction); }
Затем операционный код объединяет все это вместе, комбинируя режим адресации и операции:
public enum OpCode implements Instruction { ... @Override public void perform(Mos6502Alu alu, Registers registers, Memory memory) { addressingMode.address(registers, memory, alu, operation::perform); }
Это означает, что огромная инструкция switch становится одной строкой кода
opCode.perform(alu, registers, memory);
Проблемы
Есть некоторые инструкции, которые не очень хорошо соответствуют этому шаблону:
Режим адресации. подразумеваемый : Режим адресации подразумевается последними двумя битами байта инструкции Подразумеваемые инструкции выполняют несколько иную адресацию в зависимости от операции. Мне нужно будет разобраться с ними как с отдельными случаями, когда операции выполняют свою собственную адресацию.
Операция. JMP : Он – в отличие от всех других инструкций – передает слово (не байт) из режима адресации, который может быть режимом адресации. КОСВЕННЫЙ Они делают две вещи, из-за которых с ними трудно иметь дело. Во-первых, их адресация является АБСОЛЮТНОЙ; двухбайтовый (словесный) адрес, который Операция должна использовать для загрузки в Программный счетчик. Мы могли бы справиться с этим так же, как и с ПОДРАЗУМЕВАЕМЫМ (в том смысле, что операция затем выполняет некоторую собственную адресацию), если бы не случай, когда JMP использует КОСВЕННУЮ АБСОЛЮТНУЮ адресацию, которая будет принимать двухбайтовый аргумент, а затем с адреса, указанного этим словом, загружает двухбайтовый адрес в программный счетчик. В этом случае это невозможно сделать во время операции, потому что она понятия не имеет, что такое режим адресации.
Решение
Так что в итоге у нас получается что-то вроде гибрида:
final Mos6502OpCode opCode = Mos6502OpCode.from(nextProgramByte().getRawValue()); //Execute the opcode log.debug("Instruction: {}...", opCode.getOpCodeName()); switch (opCode){ case JMP_ABS: registers.setPC(nextProgramWord()); break; case JMP_IND: registers.setPC(getWordOfMemoryAt(nextProgramWord())); break; default: opCode.perform(alu, registers, memory); break; }
Крайние случаи убеждают, что это не самое красивое, но намного приятнее, чем было, и когда я думаю о следующей итерации, чтобы упростить это, я знаю, что мои тесты специфичны, но достаточно универсальны, чтобы поддерживать рефакторинг и редизайн.
Оригинал: “https://dev.to/rossdrew/functional-enums-in-java-34o1”