Всем привет. Не сразу, но я полюбил Rust. И эта любовь привела меня в бескрайние моря лоулевельного кода. О том, что мне удалось найти - под катом.
Секретный тип данных
Если вы читали Rust Book, то наверняка помните похожий код-сниппет:
fn unwrap<T>(option: Option<T>) -> T{ let unwrapped = match option{ Some(val) => val, None => panic!("This cannot be None!") }; return unwrapped;}fn main() { let unwrapped = unwrap(Some(0));}
Конечно, здесь нет ничего необычного. Возвращаем значение внутри Option, если оно есть, либо вызываем завершение процесса с помощью макроса panic!. Но задумывались ли вы, почему этот код компилируется? Как компилятор понимает, что функция, возвращающая T, может вернуть... это?
На самом деле, всё очень просто - макрос panic возвращает тип данных "!". Документация
Тип данных "!" просит выйти из текущего блока кода. Как это использовать? Сами разработчики языка предлагают такой вариант:
#![feature(never_type)]use std::convert::TryInto;#[derive(Debug)]enum ConnectionError{ BrokenPipe, BadId, Other}struct Client;struct Request;struct Response;impl Request{ pub fn build_response(&self) -> Response{ Response }}fn get_request(id: i32) -> Result<(Client, Request), ConnectionError>{ match id % 2 == 0{ true => { Ok((Client, Request)) }, false => { Err(ConnectionError::BadId) } }}fn init_server() -> Result<!, ConnectionError>{ loop { let (client, request) = get_request(5i32)?; let resp = request.build_response(); };}fn main() { let x: ! = init_server().unwrap();}
Однако, эта конструкция, работающая только в nightly билдах, спокойно превращается в обычный код заменой "!" на пустой тип данных "()":
fn init_server() -> Result<(), ConnectionError>{ loop { let (client, request) = get_request(5i32)?; let resp = request.build_response(); };}fn main() { let x = init_server().unwrap();}
В чём же разница? Всё очень просто, в первом примере мы не сможем полностью выполнить код:
fn main() { match init_server(){ Ok(v) => { println!("unreachable? {:?}", v); }, Err(_) => {} }; }
Компилятор любезно сообщит, что ветка Ok(v) - недостижима. Разумеется, это не помешает ему запустить программу, однако мне хотелось бы обозначить такую интересную особенность. Понятно, что она была бы недостижима и во втором примере, однако если его скомпилировать, то сообщения о недостижимом коде не будет.
Почему так происходит? Потому что то, что примет значение
v
в данном сниппете буквально означает "выход". "!"
возвращается, когда вы пишете break
,
continue
или std::process::exit
.
И, внимание, вопрос. Зачем нужна
#![feature(never_type)]
? С тех пор, как я узнал об
этом типе данных, я думал, где его можно применить. И такого места,
кажется, нет. Во всех случаях вы будете использовать panic, expect,
todo или unimplemented. К чему нужен "!"?
Странные интерфейсы
Сразу скажу, что этот вопрос скорее дискуссионный. Здесь я не буду рассказывать о малоизвестной вещи, речь пойдет о странно реализованном полиморфизме.
В Rust есть такой интерфейс (трейт - скорее абстрактный класс,
но лично мне удобнее называть его интерфейсом) Fn
. И
вроде бы с ним всё просто - все функции и лямбда-выражения
("closures" или "замыкания", если угодно), принимающие
иммутабельные входные значения, его реализуют. В чём тут
подвох?
Дело в том, что, как мы знаем, нельзя создать переменную типа данных impl Trait. Но проблема в том, что такой тип данных можно вернуть из функции, и это вызывает вопросы...
use std::any::type_name;fn type_of<T>(x: T) -> &'static str { type_name::<T>()}fn callback() -> impl Fn(f32) -> f32{ |a| { a*2. }}fn main() { let x = callback(); println!("{}", type_of(x));}
Вывод будет такой:
playground::callback::{{closure}}
. И вот, казалось
бы, переменная х имеет тип данных impl Fn(f32) ->
f32
, вот только если мы об этом явно напишем, то код не
скомпилируется. Как мы знаем, чтобы хранить trait object,
нужно использовать ключевое слово dyn. Но вот незадача - компилятор
не знает, сколько памяти будет занимать этот trait object, поэтому
необходимо такие вещи класть в кучу с помощью Box:
fn main() { let x: Box<dyn Fn(f32) -> f32> = Box::new(callback()); println!("{}", type_of(x));}
Но тогда и вывод поменяется:
alloc::boxed::Box<dyn
core::ops::function::Fn<(f32,)>+Output =
f32>
Я это понимаю так: компилятор знает, что это за имплементация, поэтому может рассчитать количество необходимой памяти.
Однако мне до сих пор не даёт покоя мысль, что такой код работает:
use tokio; // 1.0.2use tokio::task::JoinError;use futures::prelude::*; // 0.3.12async fn job1(){}async fn job2(){for i in 0..5{}}async fn job() -> Vec<impl Future<Output = Result<(), JoinError>>>{ vec![ tokio::spawn(async move{ job1().await; }), tokio::spawn(async move{ job2().await; })]}#[tokio::main]async fn main() { let mut v = job();}
С точки зрения логики компилятора, тут нет проблемы -
tokio::spawn
создаёт
структуруtokio::task::JoinHandle
. Да,
JoinHandle - это одна и та же структура, она принимает футуру,
которая создаётся блоком async{}
, однако почему
таски, содержащие разные async-блоки,
интерпретируются компилятором как одна и та же реализация? Почему
код
let v = vec![ Box::new(async{}), Box::new(async{ let cb = |x| x*2.; let val = cb(1f32); }) ];
не выполнится, а тот, что выше, выполнится? Ведь мы передаём разные реализации футур. Поделитесь в комментариях, если у вас есть мысли по этому поводу. Я честно с умным видом полчаса изучал исходники, но так и не понял.
Заключение
Rust, каким бы хорошим ни был, порой заставляет крепко задуматься. Почему retain не меняет capacity? Почему функциональные исчисления сделали ленивыми? Почему cargo создаёт странные папки с хеш-суммами на каждый случай жизни, вместо того, чтобы собрать одни и те же библиотеки один раз (хотя, справедливости ради, это не проблема самого языка)? Как бы то ни было, если писать на плюсах - это стрелять себе в ногу, то писать на расте - это пытаться стрелять себе в ногу (и не дай бог в проекте вы используете ffi, тогда попытки могут оказаться вполне успешными).
Цель этой статьи - попытаться углубиться внутрь языка, понять, как он работает изнутри, ведь, как известно, любить можно только того, кого понимаешь.