Skip to content

← Ground Rules: Values, Types, Control Flow step 11 of 24

Medium Primitives

From, Into, TryFrom: conversions that cannot lie

Convert each i64 in a slice to a u8 (an unsigned byte, 0 through 255), and report None for any value that does not fit.

pub fn to_bytes(values: &[i64]) -> Vec<Option<u8>>

The contract

  • The output has the same length and order as the input: element i of the result describes element i of values.
  • A value v with 0 <= v <= 255 becomes Some(v as u8), that is, the same number as a u8.
  • Every other value (any negative number, and anything from 256 up to i64::MAX) becomes None.
  • An empty slice gives an empty Vec.

Example: [0, 255, 256, -1] becomes [Some(0), Some(255), None, None]. The test harness prints Some(n) as the number n and None as JSON null, so the expected output is written {"value": [0, 255, null, null]}.

The starter code compiles and passes Clippy, but it converts with as, so it returns [0, 255, 0, 255] for that example. The rest of this page explains why those two wrong answers look plausible and how the standard conversion traits avoid them.

What as does with an integer that does not fit

Casting one integer type to another with as never fails. When the target type is narrower, as keeps only the low bits of the two's-complement value and discards the rest. For a u8 target that means keeping the value modulo 256:

  • 256 as u8 is 0, because 256 is 1_0000_0000 in binary and the low eight bits are all zero.
  • 300 as u8 is 44, because 300 - 256 = 44.
  • -1i64 as u8 is 255, because -1 is all one bits in two's complement.

The result is an ordinary u8. Nothing in its type records that information was lost.

From, Into and TryFrom

These three traits from the standard library describe conversions between types. Simplified, the first and third look like this:

trait From<T>    { fn from(value: T) -> Self; }
trait TryFrom<T> { type Error; fn try_from(value: T) -> Result<Self, Self::Error>; }
  • From<T> is for conversions that cannot fail. i64::from(200u8) exists because every u8 value is also a valid i64. There is no impl From<i64> for u8, so u8::from(5i64) does not compile (error E0277, "the trait bound u8: From<i64> is not satisfied").
  • Into<U> is the same conversion written from the source side. The standard library has a blanket implementation: whenever U: From<T>, it also provides T: Into<U>. That is why the usual advice is to implement From and get Into for free. let x: i64 = byte.into(); works when the target type is known from context; i64::from(byte) names the target explicitly.
  • TryFrom<T> is for conversions that can fail. try_from returns a Result: Ok(value) on success, or Err(error) describing the failure. For integer conversions the error type is std::num::TryFromIntError, so u8::try_from(300i64) is Err(..) holding a TryFromIntError, while u8::try_from(200i64) is Ok(200). The error's exact debug text is not part of the exercise and has changed between Rust versions.

The standard library provides TryFrom between every pair of primitive integer types, and From only where the conversion is lossless on every supported platform. The "every platform" part matters for usize and isize, whose width depends on the target (for example 32 bits on 32-bit targets and 64 bits on 64-bit ones). So usize::from(5u16) compiles but usize::from(5u32) does not, even on a 64-bit machine where every u32 would fit, and you use usize::try_from instead. In other words, a missing From impl means "not guaranteed lossless everywhere", not necessarily "lossy here".

From Result to Option with .ok()

This exercise needs to know whether a conversion failed, not why, so the error value can be discarded. Result::ok does exactly that:

u8::try_from(v).ok()      // Result<u8, TryFromIntError> -> Option<u8>

.ok() turns Ok(x) into Some(x) and Err(_) into None. The opposite direction, Option::ok_or(err), turns None into Err(err) when you need to supply an error yourself.

Comparing the two approaches

input v as u8 u8::try_from(v).ok()
0 0 Some(0)
255 255 Some(255)
256 0 None
-1 255 None

In the as column, the results for 256 and -1 are valid bytes, so a reader of the output cannot tell them apart from correct conversions. In the try_from column, the failures appear in the type as None.

This does not mean as is always wrong. It is the right tool when you want the wrap-around behavior on purpose (for example, taking the low byte of a checksum), or when the value's range has already been checked. Note also that as from a float to an integer behaves differently from integer to integer: it saturates, so 300.7_f64 as u8 is 255, -5.0_f64 as u8 is 0 and f64::NAN as u8 is 0. A reasonable default is to use TryFrom when a value might be out of range, and to use as only when you have decided what should happen to out-of-range values.