For all practical purposes, bytecode is just a data structure that is convenient for an interpreter to use. The interpreter looks at each instruction in the byte code and immediately performs that action.
A simple interpreter for arithmetic expressions might look like this when written in Python:
bytecode = [
{'type': 'const', 'value': 40},
{'type': 'const', 'value': 2},
{'type': '+'},
{'type': 'print'},
]
stack = []
for instruction in bytecode:
action = instruction['type']
if action == 'const':
stack.append(instruction['value'])
elif action == '+':
right = stack.pop()
left = stack.pop()
stack.append(left + right)
elif action == 'print':
print(stack.pop())
else:
raise TypeError(f'Unknown instruction type {action}')
That is, the interpreter checks the type of the type of the instruction and selects which code snippet to run depending on that action. The interpreter is not translating the bytecode into another language in the sense that we'd get a program in that language, but it does map the instructions to code snippets.
Thus, the CPython interpreter does not translate Python bytecode into C code, but selects which C code snippet to run depending on the instruction.
This is useful, because generating C code that works correctly and is as fast as expected from C is quite tricky. Interpreters typically spend a lot of time doing extra bookkeeping (like reference counting), and in the dispatch logic itself – that loop through all instructions isn't quite free.
Programs that translate a source language into another language are sometimes called a transpiler (a kind of compiler that doesn't output machine code). It is easy to create a transpiler that just calls into the code snippets of an interpreter (sometimes called threaded code in older literature):
bytecode = [
{'type': 'const', 'value': 40},
{'type': 'const', 'value': 2},
{'type': '+'},
{'type': 'print'},
]
# built-in function for our "compiled" code to call
def do_const(stack, value):
stack.append(value)
def do_add(stack):
right = stack.pop()
left = stack.pop()
stack.append(left + right)
def do_print(stack):
print(stack.pop())
# assembling Python source code for our "bytecode"
code = 'stack = []\n'
for instruction in bytecode:
action = instruction['type']
if action == 'const':
code += f'do_const(stack, {instruction["value"]})\n'
elif action == '+':
code += 'do_add(stack)\n'
elif action == 'print':
code += 'do_print(stack)\n'
else:
raise TypeError(f'Unknown instruction type {action}')
# we can now execute the code by "compiling" it as Python:
exec(code, locals(), {})
The resulting code might be slightly faster because we've gotten rid of the dispatch logic, but we still have interpreter overhead like stack manipulation. The code we've generated doesn't look like normal Python code. But to get to that Python code we still had to run through our dispatch logic, and now Python has to parse the code we've generated. Similarly, a Python interpreter that translates to C wouldn't be very fast.
The Java reference implementation OpenJDK/HotSpot is interesting because its runtime combines a just-in-time compiler with an interpreter. By default, it interprets byte code with an interpreter written in C++. But if the same code is executed often enough, it compiles that part of the code directly to machine code. Depending on how important that code is, HotSpot spends more effort on optimizing the machine code. This allows Java to be as fast as C in some benchmarks. CPython is very simplistic in comparison.
There is (was?) a Python implementation called Jython that was written in Java. Jython works by compiling the Python code to Java byte code. That byte code is then handled by the Java virtual machine, which either interprets it or compiles it on the fly to machine code, as discussed above. Because the Java runtime is awesome this could make that Python code run very fast, in some circumstances. But the added complexity also comes at a cost. Additionally, Jython is not compatible with Python modules that need to interact with internal CPython data structures.