Для чего нужна данная статья? :
- Получить представление об инкапсуляции (Инкапсуляция — это то, что не даёт вам открыть кофе машину и случайно налить туда соль вместо сахара) в Rust для реализации ML.
Зачем Вам это уметь? :
- снизить сложность и облегчить обслуживание и обновление кода.
- скрыть детали внутренней реализации структуры или модуля от внешнего кода.
- определить четкий интерфейс для взаимодействия внешнего кода с внутренним кодом.
- улучшить общий дизайн кода и упростить его использование.
- создавать большие программы из более мелких частей.
Что такое модификаторы видимости?:
pub и priv, можно использовать для управления видимостью полей структуры, функций модуля и методов. По умолчанию все структурные поля и функции модуля являются частными, и нам нужно использовать ключевое слово pub, чтобы сделать их общедоступными. Для того чтобы структуру было видно извне её модуля, её нужно помечать, как публичную.
Пример:
struct Person {
pub name: String,
age: u8,
}
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Полная инкапсуляция (по умолчанию, private)
Всё в Rust по умолчанию закрыто (private), если явно не указать pub.
struct Model {
weights: Vec<f32>, // Приватное поле
}
impl Model {
fn new() -> Self {
Self {
weights: vec![0.1, 0.5, 0.9],
}
}
fn predict(&self, input: &[f32]) -> f32 {
self.weights.iter().zip(input.iter()).map(|(w, i)| w * i).sum()
}
}
fn main() {
let model = Model::new();
let prediction = model.predict(&[1.0, 2.0, 3.0]);
println!("Prediction: {}", prediction);
}
Где используется
- Скрытие внутренних данных (например, весов нейросети).
- Запрет на прямое изменение состояний объектов.
Глобальная инкапсуляция (pub)
Сделает структуру или метод доступным везде.
pub struct Model {
pub weights: Vec<f32>,
}
impl Model {
pub fn new() -> Self {
Self {
weights: vec![0.1, 0.5, 0.9],
}
}
pub fn predict(&self, input: &[f32]) -> f32 {
self.weights.iter().zip(input.iter()).map(|(w, i)| w * i).sum()
}
}
Где используется
- Открытый API библиотек (pub fn).
- Доступ к структурам, которые должны быть видны везде.
Инкапсуляция в пределах модуля (pub(crate))
Доступ только внутри текущего крейта.
pub(crate) struct Model {
weights: Vec<f32>,
}
impl Model {
pub(crate) fn new() -> Self {
Self {
weights: vec![0.1, 0.5, 0.9],
}
}
pub(crate) fn predict(&self, input: &[f32]) -> f32 {
self.weights.iter().zip(input.iter()).map(|(w, i)| w * i).sum()
}
}
Где используется
- Скрытие деталей реализации внутри библиотеки.
- Разделение API на внешнюю и внутреннюю часть.
Инкапсуляция в пределах родительского модуля (pub(super))
Доступен только в родительском модуле.
mod model {
pub(super) struct Model {
weights: Vec<f32>,
}
impl Model {
pub(super) fn new() -> Self {
Self {
weights: vec![0.1, 0.5, 0.9],
}
}
}
}
fn main() {
// Ошибка: Model не доступен в этом модуле
// let model = model::Model::new();
}
Где используется
- Если модуль содержит вспомогательные структуры, но их нельзя использовать за его пределами.
Инкапсуляция в пределах конкретного модуля (pub(in ...))
Можно ограничить доступ только для определённого модуля.
mod model {
pub(in crate::utils) struct Model {
weights: Vec<f32>,
}
impl Model {
pub(in crate::utils) fn new() -> Self {
Self {
weights: vec![0.1, 0.5, 0.9],
}
}
}
}
mod utils {
use crate::model::Model;
pub fn print_model() {
let model = Model::new();
println!("Model created!");
}
}
Где используется
- Гранулярный контроль доступа, когда API должно быть доступно только в определённой части кода.
Геттеры и сеттеры (контролируемый доступ)
Можно сделать поля приватными, но добавить pub fn-методы для контроля доступа.
pub struct Model {
weights: Vec<f32>,
}
impl Model {
pub fn new() -> Self {
Self {
weights: vec![0.1, 0.5, 0.9],
}
}
// Геттер
pub fn get_weights(&self) -> &[f32] {
&self.weights
}
// Сеттер с проверкой
pub fn set_weights(&mut self, new_weights: Vec<f32>) {
if new_weights.len() == self.weights.len() {
self.weights = new_weights;
} else {
println!("Ошибка: Неверное количество весов!");
}
}
}
Где используется
- Когда данные можно просматривать, но нельзя изменять напрямую.
- Безопасное изменение данных с валидацией.
Инкапсуляция через impl Trait
Позволяет скрыть конкретный тип и возвращать только интерфейс.
trait Model {
fn predict(&self, input: &[f32]) -> f32;
}
struct NeuralNetwork;
impl Model for NeuralNetwork {
fn predict(&self, input: &[f32]) -> f32 {
input.iter().sum() // Заглушка
}
}
fn get_model() -> impl Model {
NeuralNetwork
}
fn main() {
let model = get_model();
let input = [1.0, 2.0, 3.0];
println!("Prediction: {}", model.predict(&input));
}
Где используется
- Скрытие реализации модели от пользователя.
- Гибкость при выборе модели.
Инкапсуляция через enum
Можно скрыть детали реализации, предоставляя только высокоуровневый интерфейс.
enum Model {
LinearRegression,
NeuralNetwork,
}
impl Model {
fn predict(&self, input: &[f32]) -> f32 {
match self {
Model::LinearRegression => input.iter().sum(),
Model::NeuralNetwork => input.iter().map(|x| x * 0.5).sum(),
}
}
}
fn main() {
let model = Model::NeuralNetwork;
let input = [1.0, 2.0, 3.0];
println!("Prediction: {}", model.predict(&input));
}
Где используется
- Полиморфизм без использования трейтов.
- Скрытие деталей алгоритма внутри одного типа.
Что такое инкапсуляция поведения?:
Возможность инкапсулировать поведение, определяя методы в структуре. Это позволяет инкапсулировать логику использования структуры и обеспечить четкий интерфейс для взаимодействия внешнего кода со структурой.
Пример:
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
Как написать generic инкапсуляцию?:
Пример:
pub struct AveragedCollection<T> {
list: Vec<T>,
average: f64,
}
impl<T: std::ops::Add<Output = T> + std::ops::Div<Output = T> + Copy>
AveragedCollection<T> {
pub fn new() -> AveragedCollection<T> {
AveragedCollection {
list: Vec::new(),
average: 0.0,
}
}
pub fn add(&mut self, value: T) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<T> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
},
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total = self.list.iter().fold(T::default(), |a, &b| a + b);
self.average = (total / T::from(self.list.len() as i32).unwrap()).to_f64().unwrap();
}
}
Как написать инкапсуляцию Enum + inner structs?:
Пример:
pub struct AveragedCollection<T, F>
where F: Fn(&Vec<T>) -> f64,
{
list: Vec<T>,
average: f64,
averager: F,
}
impl<T, F> AveragedCollection<T, F>
where F: Fn(&Vec<T>) -> f64,
{
pub fn new(averager: F) -> AveragedCollection<T, F> {
AveragedCollection {
list: Vec::new(),
average: 0.0,
averager,
}
}
pub fn add(&mut self, value: T) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<T> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
},
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
self.average = (self.averager)(&self.list);
}
}
Как написать инкапсуляцию Traits + Generics?:
Пример:
pub trait Average {
fn add(&mut self, value: Self::Item);
fn remove(&mut self) -> Option<Self::Item>;
fn average(&self) -> f64;
type Item;
}
pub struct AveragedCollection<T> {
list: Vec<T>,
average: f64,
}
impl<T: std::ops::Add<Output = T> + std::ops::Div<Output = T> + Copy + From<i32>> Average
for AveragedCollection<T>
{
type Item = T;
fn add(&mut self, value: Self::Item) {
self.list.push(value);
self.update_average();
}
fn remove(&mut self) -> Option<Self::Item> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
},
None => None,
}
}
fn average(&self) -> f64 {
self.average
}
}
impl<T: std::ops::Add<Output = T> + std::ops::Div<Output = T> + Copy + From<i32>> AveragedCollection<T> {
pub fn new() -> AveragedCollection<T> {
AveragedCollection {
list: Vec::new(),
average: 0.0,
}
}
fn update_average(&mut self) {
let total = self.list.iter().fold(T::default(), |a, &b| a + b);
self.average = (total / T::from(self.list.len() as i32)).to_f64().unwrap();
}
}
Как использовать модули для контроля доступа.?
Каждый модуль может содержать свои собственные функции, структуры, константы и другие элементы, которые могут быть публичными или приватными. Модули также могут быть вложенными.
mod outer_module {
pub mod inner_module {
pub fn public_function()
{
println!("This is a public function.");
}
fn private_function() {
println!("This is a private function.");
}
}
}
outer_module::inner_module::public_function();
// Доступно outer_module::inner_module::private_function();
Как инкапсулировать через методы доступа (Encapsulation through Accessor Methods)?:
В Rust можно инкапсулировать поля структуры, делая их приватными, и предоставлять доступ к ним через публичные методы, такие как геттеры и сеттеры.
struct MyStruct {
field: i32,
}
impl MyStruct
{
pub fn new(val: i32) -> MyStruct
{
MyStruct { field: val }
}
pub fn get_field(&self) -> i32 {
self.field
}
pub fn set_field(&mut self, val: i32) {
self.field = val;
}
}
let mut s = MyStruct::new(10); println!("{}", s.get_field()); // Доступ через геттер
s.set_field(20); // Изменение через сеттер
Способы инкапсуляции мутабельности
Интерьерная мутабельность с помощью Cell<T>
Описание: Cell<T> — это простая абстракция для типов, реализующих трейт Copy (например, i32, bool, f64). Она позволяет изменять внутреннее состояние через неизменяемую ссылку (&T), но без проверки заимствований на этапе компиляции.
Особенности:Использует методы .get() для чтения и .set() для записи.
Не требует заимствований, так как работает с копиями значений.
Подходит для простых типов, где нет необходимости в ссылках.
Пример:
use std::cell::Cell;
let cell = Cell::new(5);
cell.set(10);
println!("{}", cell.get()); // Выведет: 10
Ограничения: Не безопасно для многопоточных сценариев, так как не обеспечивает атомарности операций. Также может привести к ошибкам, если значения перезаписываются без должного контроля.
Интерьерная мутабельность с помощью RefCell<T>
Описание: RefCell<T> позволяет изменять внутреннее состояние через неизменяемую ссылку (&T) для любых типов, не только Copy. В отличие от Cell<T>, она использует runtime-проверку заимствований, что означает, что нарушение правил (например, одновременное наличие нескольких изменяемых заимствований) приведет к панике во время выполнения.
Особенности:Методы .borrow() и .borrow_mut() используются для получения неизменяемой или изменяемой ссылки соответственно.
Подходит для однопоточных сценариев, где компилятор не может гарантировать корректность заимствований на этапе компиляции.
Может быть полезно для кэширования в неизменяемых структурах данных.
Пример:
use std::cell::RefCell;
let refcell = RefCell::new(5);
*refcell.borrow_mut() = 10;
println!("{}", *refcell.borrow()); // Выведет: 10
Ограничения: Не безопасно для многопоточных сценариев, так как runtime-проверка не защищает от гонок данных. Также может привести к панике, если правила заимствования нарушены.
Потокобезопасная мутабельность с помощью Mutex<T>
Описание: Mutex<T> (взаимное исключение) обеспечивает безопасный доступ к изменяемым данным в многопоточных сценариях. Только один поток может получить доступ к данным в любой момент времени, что предотвращает гонки данных.
Особенности:Использует метод .lock() для получения доступа, который блокирует другие потоки до освобождения.
Доступ автоматически освобождается, когда MutexGuard выходит за пределы области видимости.
Есть метод .try_lock() для неблокирующего доступа.
Пример:
use std::sync::Mutex;
let mutex = Mutex::new(5);
let mut guard = mutex.lock().unwrap();
*guard = 10;
println!("{}", *guard); // Выведет: 10
Ограничения: Может привести к панике, если поток пытается заблокировать уже заблокированный Mutex без использования .try_lock(). Производительность может быть ниже по сравнению с однопоточными решениями из-за накладных расходов на синхронизацию.
Потокобезопасная мутабельность с помощью RwLock<T>
Описание: RwLock<T> (чтение-запись замок) позволяет нескольким потокам одновременно читать данные, но только одному потоку писать. Это более эффективно, чем Mutex<T>, в сценариях, где чтение происходит чаще, чем запись.
Особенности:Методы .read() и .write() используются для получения доступа к данным.
Поддерживает неблокирующие версии .try_read() и .try_write().
Доступ автоматически освобождается, когда RwLockReadGuard или RwLockWriteGuard выходит за пределы области видимости.
Пример:
use std::sync::RwLock;
let rwlock = RwLock::new(5);
let read_guard = rwlock.read().unwrap();
println!("{}", *read_guard); // Чтение
let mut write_guard = rwlock.write().unwrap();
*write_guard = 10; // Запись
Ограничения: Производительность может быть ниже, чем у Cell или RefCell, из-за накладных расходов на синхронизацию. Требует осторожности при управлении блокировками для предотвращения дедлоков.
Использование UnsafeCell<T>
Описание: UnsafeCell<T> — это низкоуровневый примитив, который позволяет отключить проверку неизменяемости для неизменяемых ссылок (&T). Это единственный способ в Rust, который позволяет безопасно мутировать данные через общие ссылки, но требует ручной гарантии безопасности.
Особенности:Обычно используется внутри других безопасных абстракций, таких как Cell<T> или RefCell<T>.
Не рекомендуется для прямого использования, так как это unsafe-инструмент, и ошибки могут привести к неопределенному поведению.
Пример:
use std::cell::UnsafeCell;
let unsafe_cell = UnsafeCell::new(5);
unsafe { *unsafe_cell.get() = 10; }
Ограничения: Требует глубокого понимания правил безопасности Rust. Не подходит для большинства сценариев, где есть безопасные альтернативы.
Комбинации для сложных сценариев
Для случаев, когда нужно комбинировать несколько концепций, можно использовать:
Rc<RefCell<T>>: Позволяет иметь несколько владельцев (Rc — Reference Counting) для данных, которые можно изменять через RefCell<T>. Подходит для однопоточных сценариев с разделяемой собственностью.
Пример:
use std::rc::Rc;
use std::cell::RefCell;
let value = Rc::new(RefCell::new(5));
let clone = value.clone();
*clone.borrow_mut() = 10;
Arc<Mutex<T>>: Аналогично, но для многопоточных сценариев, где Arc (Atomic Reference Counting) обеспечивает потокобезопасное совместное владение, а Mutex<T> — изменяемость.
Пример:
use std::sync::{Arc, Mutex};
let value = Arc::new(Mutex::new(5));
let clone = Arc::clone(&value);
let mut guard = clone.lock().unwrap();
*guard = 10;
Рекомендации
- Выбор метода: Для однопоточных сценариев предпочтительно использовать Cell<T> для простых типов и RefCell<T> для сложных структур. Для многопоточных приложений выбор между Mutex<T> и RwLock<T> зависит от модели доступа: если чтение преобладает, RwLock<T> может быть эффективнее.
- Производительность: Интерьерная мутабельность (Cell<T>, RefCell<T>) имеет меньшие накладные расходы по сравнению с потокобезопасными механизмами, но не подходит для многопоточных сценариев.
- Безопасность: Все перечисленные методы, кроме UnsafeCell<T>, предоставляют безопасные API, что минимизирует риск ошибок. Однако разработчикам следует быть внимательными к потенциальным паникам в RefCell<T> или блокировкам в Mutex<T>/RwLock<T>.
Как использовать инкапсуляцию в ML
Можно определить структуры, представляющие модели машинного обучения, где внутренние параметры, такие как веса, являются приватными, а методы для обучения и предсказания — публичными. Это предотвращает случайные изменения внутренних данных и обеспечивает их целостность.
Пример:
pub struct NeuralNetwork {
weights: Vec<f64>, // приватное поле
// другие приватные поля
}
impl NeuralNetwork {
pub fn new() -> Self {
// инициализация
}
pub fn train(&mut self, data: &Vec<Vec<f64>>, labels: &Vec<f64>) {
// обучение модели
}
pub fn predict(&self, input: &Vec<f64>) -> f64 {
// предсказание
}
}
Данные для обучения часто требуют предобработки, например, нормализации или аугментации. Инкапсуляция позволяет скрыть эти детали, предоставляя пользователю простой интерфейс для работы с данными.
Пример:
pub struct Dataset {
data: Vec<Vec<f64>>, // приватное поле
labels: Vec<f64>, // приватное поле
}
impl Dataset {
pub fn from_csv(path: &str) -> Self {
// загрузка и предобработка данных
}
pub fn get_batch(&self, batch_size: usize) -> (Vec<Vec<f64>>, Vec<f64>) {
// возврат мини-пакета данных
}
}
Алгоритмы оптимизации, такие как градиентный спуск, могут быть инкапсулированы с помощью трейтов (интерфейсов), что позволяет скрыть их внутреннюю реализацию и предоставить общий интерфейс.
Пример:
pub trait Optimizer {
fn step(&mut self, gradients: &Vec<f64>);
}
pub struct SGD {
learning_rate: f64, // приватное поле
// другие параметры
}
impl Optimizer for SGD {
fn step(&mut self, gradients: &Vec<f64>) {
// реализация шага оптимизации
}
}
Инкапсуляция позволяет управлять состоянием модели, например, для сохранения и загрузки модели из файла. Внутреннее состояние хранится в приватных полях, а публичные методы предоставляют интерфейс для сериализации и десериализации.
Пример:
impl NeuralNetwork {
pub fn save(&self, path: &str) {
// сохранение модели в файл
}
pub fn load(path: &str) -> Self {
// загрузка модели из файла
}
}
В ML-фреймворках, таких как TensorFlow, используются вычислительные графы. В Rust можно инкапсулировать создание и управление этими графами, предоставляя простой API для пользователей.
Пример:
pub struct ComputationGraph {
nodes: Vec<Node>, // приватное поле
// другие компоненты
}
impl ComputationGraph {
pub fn new() -> Self {
// инициализация графа
}
pub fn add_node(&mut self, node: Node) {
// добавление узла в граф
}
pub fn compute(&self, inputs: &Vec<f64>) -> Vec<f64> {
// выполнение вычислений
}
}
Инкапсуляция позволяет обрабатывать ошибки и валидировать данные внутри структур и методов, что делает код более надежным. Например, при создании модели можно проверять корректность входных параметров.
Пример:
impl NeuralNetwork {
pub fn new(layers: Vec<usize>) -> Result<Self, String> {
if layers.is_empty() {
return Err("Layers cannot be empty".to_string());
}
// инициализация модели
Ok(NeuralNetwork { /* ... */ })
}
}
В ML часто используются параллельные вычисления для ускорения обучения. Инкапсуляция позволяет скрыть детали параллелизма, предоставляя простой интерфейс для пользователя.
Пример:
pub struct ParallelTrainer {
model: NeuralNetwork, // приватное поле
// другие параметры
}
impl ParallelTrainer {
pub fn train(&self, dataset: &Dataset) {
// параллельное обучение модели
}
}