| категории | закладки | история | добавить | RSS |
  

15:36 02-03-2018 Virtuos86

Ти́ повые состояния (`typestates`) in Rust
Мой вольный авторский перевод заинтересовавшего меня поста, который я прочитал, просматривая свежий выпуск "Неделя в Rust". Оригинал можно найти здесь. Но лучше прочитать мой перевод.

Давным давно, Rust был языком с типовыми состояниями. Официально типовые состояния были выкинуты задолго до Rust 1.0. В этом тексте я раскрою вам секретный секрет сообщества Rust: в Rust по-прежнему есть типовые состояния. БА-ДУМ-ТССС!

Секундочку, а что такое "типовое состояние"?

Рассмотрим объект, представляющий файл — давайте обозначим его структурой данных MyFile. До того момента, как MyFile будет открыт, он не может быть прочитан. В случае если MyFile закрыт, он также не может быть прочитан. Между двумя этими состояниями файл может быть прочитан. Типовые состояния это механизм, дающий тайпчекеру возможность проверить, что вы не делаете следующих ошибок:


fn read_contents_of_file(path: &Path) -> String {
let mut my_file = MyFile::new(path);
my_file.open(); // Ошибка: эта операция может завершиться неудачно.
let result = my_file.read_all(); // Невалидно, если `my_file.open()` завершилось неудачно.
my_file.close();
my_file.seek(Seek::Start); // Ошибка: `my_file` уже закрыт.
result
}


В этом примере мы сделали две ошибки:
1. мы прочитали файл, который может не быть открытым;
2. мы установили текущую позицию чтения в файле, который уже был закрыт.

В большинстве языков программирования мы легко можем спроектировать API MyFile так, чтобы быть уверенными, что первая ошибка будет невозможной, просто бросая исключение, когда файл не получается открыть. Некоторые стандартные библиотеки и рамки решают не следовать этому принципу ради гибкости, но возможности существуют в самом языке.

Вторую ошибку намного сложнее перехватить. Большинство ЯП поддерживают необходимые возможности, чтобы сделать эту ошибку сложной для воспроизведения, как правило, закрывая файл при уничтожении или в конце вызова функции высокого порядка, но единственный неакадемический язык, о котором я знаю, который может фактически полностью предотвратить эту ошибку, это Rust.

Тривиальные типовые состояния в Rust

Итак, как мы делаем это в Rust?

Простейший способ сделать это — ввести здоровые типы (ради Бога, если знаете более приличный перевод термина "sane types", сообщите мне) для наших операций над MyFile:

impl MyFile {
// `open` это единственный способ создания экземпляра `MyFile`.
pub fn open(path: &Path) -> Result { ... }

// `seek` требует экземпляр `MyFile`.
pub fn seek(&mut self, pos: Seek) -> Result { ... }

// `read_all` требует экземпляр `MyFile`.
pub fn read_all(&mut self) -> Result { ... }

// `close` принимает аргумент `self`, не ссылки `&self` или `&mut self`,
// который означает, что этот метод *перемещает* свой объект, эффективно поглощая его.
pub fn close(self) -> Result { ... }
}

impl Drop for MyFile {
// Определяем деструктор для автоматического закрытия экземпляра `MyFile`,
// если мы еще не закрыли его.
fn drop(&mut self) { ... }
}


Давайте перепишем наш изначальный пример:

fn read_contents_of_file(path: &Path) -> Result {
let mut my_file = MyFile::open(path)?;
// Обратите внимание на `?`. Это простой оператор, который просит
// компилятор проверить успешность выполнения операции.
// *Единственный* способ получить экземпляр `MyFile`
// это иметь успешно завершенным `MyFile::open`.

// В этой строке `my_file` это объект `MyFile`,
// который можно использовать.

let result = my_file.read_all()?; // Корректно.
my_file.close(); // Корректно.

// Поскольку `my_file.close()` поглощает `my_file`,
// эта переменная больше не существут.

my_file.seek(Seek::Start)?; // Ошибка, которую обнаружит компилятор.
result
}

Это работает и в более сложных случаях:

fn read_contents_of_file(path: &Path) -> Result {
// Аналогично.
let mut my_file = MyFile::open(path)?;
let result = my_file.read_all()?; // Корректно.

if are_we_happy_yet() {
my_file.close(); // Корректно.
}

// Поскольку `my_file.close()` поглощает `my_file`,
// эта переменная больше не существует, по крайней мере,
// в данной ветви выполнения кода.

my_file.seek(Seek::Start)?; // Ошибка, которую обнаружит компилятор.
result

// Если мы не закрыли `my_file`, то по выходе из блока кода,
// в котором мы его определили (в данном случае, это тело функции),
// здесь сработает его деструктор, который закроет файл.
}


Ключевой момент заключается в том, что система типов Rust обеспечивает соблюдение того факта, что переменная не может быть использована после перемещения. В нашем случае my_file.close() переместил значение, эффективно поглотив его.

Даже если бы мы попытались скрыть переменную где-то в другом месте и повторно использовать её после вызова my_file.close(), эта попытка была бы пресечена компилятором:

fn read_contents_of_file(path: &Path) -> Result {
// Аналогично.
let mut my_file = MyFile::open(path)?;
let result = my_file.read_all()?;

let mut my_file_sneaky_backup = my_file;
// Здесь мы переместили `my_file` в `my_file_sneaky_backup`.
// Поэтому мы не можем больше использовать.

my_file.close(); // Ошибка, которую обнаружит компилятор.

my_file_sneaky_backup.seek(Seek::Start)?;
result

// Если мы не закрыли `my_file`,
// здесь сработает его деструктор, который закроет файл.
}


Давайте испробуем иной путь, чтобы обмануть компилятор, для доступа к файлу после его закрытия:

fn read_contents_of_file(path: &Path) -> Result {
let my_shared_file = Rc::new(RefCell::new(MyFile::open(path)?));
// Здесь `my_shared_file` это shared указатель на мутабельный
// экземпляр `MyFile`, наподобие обычной
// ссылки в Java, C# или Python.

let result = my_shared_file.borrow_mut()
.read_all()?; // Корректно.

let my_shared_file_sneaky_backup = my_shared_file.clone();
// Мы клонировали указатель, создав второй способ доступа к `my_shared_file`.

// Давайте удостоверимся, что мы можем использовать бэкап
// и оригинальный файл:
my_shared_file_sneaky_backup.seek(Seek::Start)?; // Корректно.
my_shared_file.seek(Seek::Start)?; // Также корректно.

// Ха-ха, теперь-то мы, конечно, можем закрыть `my_shared_file`,
// а потом манипулировать `my_shared_file_sneaky_backup`,
// словно мы в Java, C# или Python!

// Да вот беда, мы не можем вызвать `my_shared_file.close()`,
// потому что укрытый `MyFile` разделен (shared),
// что означает, что никто не может *перемещать* его.
my_shared_file.close(); // Ошибка, которую обнаружит компилятор.
my_shared_file_sneaky_backup.seek(Seek::Start)?;
result

// Если мы не закрыли `my_file`, деструктор закроет его здесь.
}


И снова наши поползновения были пресечены компилятором. В самом деле, если явно не перейти в unsafe режим, мы не сможем разбить инвариант, в котором seek не может быть использован после закрытия.

Этот пример демонстрирует первый кирпич типовых состояний в Rust: типизированную операцию перемещения. Но мы разобрались только с тривиальным случаем, в котором файлы имеют лишь два состояния: открыт и закрыт.

Давайте посмотрим, как мы можем управиться с более сложными случаями.

Сложные типовые состояния

Вместо файлов, давайте теперь рассмотрим следующий (и, по общему признанию, довольно тупой) сетевой протокол:
1. Отправитель отправляет сообщение "HELLO".
2. Приемник получает сообщение "HELLO", отвечает сообщением "HELLO, YOU".
3. Отправитель получает сообщение "HELLO, YOU", отвечает случайным числом.
4. Получатель получает номер от отправителя, отвечает тем же номером.
5. Отправитель получает одинаковое количество от приемника, отвечает "BYE".
6. Получатель получает "BYE" от отправителя, отвечает "BYE, YOU".
7. Вернитесь к 1.

Любое другое сообщение игнорируется.

Мы можем спроектировать нашего Отправителя (и аналогично Получателя) так, чтобы гарантировать, что операции выполняются в правильном порядке. На данный момент мы не заботимся об идентификации корреспондента или числа.

Для этой цели мы объединим типовые состояния с другим методом, хорошо известным функциональным программистам, таким как фантомные типы.

// Серия типов нулевого размера (нет полей => нет состояния => в рантайме не существуют),
// представляющих состояние отправителя.
// Значение не имеет значения, только тип
// (отсюда и название "фантомный тип").
struct SenderReadyToSendHello;
struct SenderHasSentHello;
struct SenderHasSentNumber;
struct SenderHasReceivedNumber;

struct Sender {
/// Актуальная реализация сетевого I/O.
inner: SenderImpl;

/// Поле нулевого размера, не существующее во время выполнения.
state: S;
}

/// Следующие методы могут быть вызваны независимо от состояния.
impl Sender {
/// Порт, используемый для подключения отправителя.
fn port(&self) -> usize;

/// Закрыть отправителя, раз и навсегда.
fn close(self);
}

/// Следующий метод может быть вызван только в состоянии SenderReadyToSendHello.
impl Sender {
/// Отправить приветствие.
///
/// Этот метод поглощает отправителя в его текущем состоянии
/// и возвращает его в новом состоянии.
fn send_hello(mut self) -> Sender {
self.inner.send_message("HELLO");
Sender {
/// Перемещаем реализацию сетевого I/O.
/// Компилятор достаточно умён,
/// чтобы понять, что это не требует никакой рантайм-операции.
inner: self.inner,

/// Заменяем поле нулевого размера.
/// Эта операция будет вычищена во время выполнения.
state: SenderHasSentHello
}
}
}

/// Следующий метод может быть вызван только в состоянии SenderHasSentHello.
impl Sender {
/// Ждать, пока получатель не отправит "HELLO, YOU",
/// ответить числом.
///
/// Вернуть отправитель в состоянии `SenderHasSentNumber`
fn wait_respond_to_hello_you(mut self) -> Sender {
// ...
}

/// Если получатель отправляет "HELLO, YOU", ответить числом и
/// вернуть отправитель в состоянии `SenderHasSentNumber`.
///
/// Иначе вернуть неизмененное состояние.
fn try_respond_to_hello_you(mut self) -> Result, Self> {
// ...
}
}

/// Следующий метод может быть вызван только в состоянии SenderHasSentNumber.
impl Sender {
/// Ждать, пока получатель не отправит число, ответить "BYE".
///
/// Вернуть отправитель в состоянии `SenderReadyToSendHello`
fn wait_respond_to_hello_you(mut self) -> Sender {
// ...
}

/// Если получатель отправляет число, ответить и вернуть отправитель
/// в состоянии `SenderReadyToSendHello`.
///
/// Иначе вернуть неизмененное состояние.
fn try_respond_to_hello_you(mut self) -> Result, Self> {
// ...
}
}


Из этого дизайна очевидно, что Отправитель может следовать только такому протоколу:

1. из шага 1 (SenderReadyToSendHello) он может идти только к шагу 3;
2. из шага 3 (SenderHasSentHello) он может только либо оставаться на шаге 3 или идти к шагу 5;
3. из шага 5 (SenderHasSentNumber) он может только либо оставаться на шаге 5 или идти к шагу 1.

Любая попытка нарушить протокол будет заблокирована системой типов.

Если вам когда-нибудь понадобится работать с сетевыми протоколами, драйверами устройств, промышленными устройствами со специфичными инструкциями безопасности или OpenGL/DirectX/чем угодно еще, что потребует от вас выполнения сложных взаимодействий с "железом", вы определенно будете счастливы иметь такие гарантии!

Добро пожаловать в мир типовых состояний.

Краткое примечание: за типовые состояния

Продолжая наш сетевой пример, что если мы захотим, скажем, сохранять число, отправленное сервером, чтобы проверить, что означает ответ? Чтобы сделать это, мы можем сохранять число в SenderHasSentNumber:

struct SenderHasSentNumber {
number_sent: u32,
}


И снова, компилятор будет проверять, что код получает доступ к number_sent, только когда отправитель находится в состоянии SenderHasSentNumber.

Мы потеряем (крошечный) объем производительности, компилятор не сможем оптимизировать трансформацию Отправителя между идентичными представлениями, но вообще это того стоит.



Virtuos86
2018-03-02T15:36:45Z
1 нормальный
Рейтинг: 4
голосов: 4


Trust Rust №6: Типовые состояния (перевод)

- Ти́ повые состояния (`typestates`) in Rust
Мой вольный авторский перевод заинтересовавшего меня поста, который я прочитал, просматривая свежий выпуск "Неделя в Rust". Оригинал можно найти здесь. Но лучше прочитать мой перевод.

Давным давно, Rust был языком с типовыми состояниями. Официально типовые состояния были выкинуты задолго до Rust 1.0. В этом тексте я раскрою вам секретный секрет сообщества Rust: в Rust по-прежнему есть типовые состояния. БА-ДУМ-ТССС!

Секундочку, а что такое "типовое состояние"?

Рассмотрим объект, представляющий файл — давайте обозначим его структурой данных MyFile. До того момента, как MyFile будет открыт, он не может быть прочитан. В случае если MyFile закрыт, он также не может быть прочитан. Между двумя этими состояниями файл может быть прочитан. Типовые состояния это механизм, дающий тайпчекеру возможность проверить, что вы не делаете следующих ошибок:

fn read_contents_of_file(path: &Path) -> String {
let mut my_file = MyFile::new(path);
my_file.open(); // Ошибка: эта операция может завершиться неудачно.
let result = my_file.read_all(); // Невалидно, если `my_file.open()` завершилось неудачно.
my_file.close();
my_file.seek(Seek::Start); // Ошибка: `my_file` уже закрыт.
result
}


В этом примере мы сделали две ошибки:
1. мы прочитали файл, который может не быть открытым;
2. мы установили текущую позицию чтения в файле, который уже был закрыт.

В большинстве языков программирования мы легко можем спроектировать API MyFile так, чтобы быть уверенными, что первая ошибка будет невозможной, просто бросая исключение, когда файл не получается открыть. Некоторые стандартные библиотеки и рамки решают не следовать этому принципу ради гибкости, но возможности существуют в самом языке.

Вторую ошибку намного сложнее перехватить. Большинство ЯП поддерживают необходимые возможности, чтобы сделать эту ошибку сложной для воспроизведения, как правило, закрывая файл при уничтожении или в конце вызова функции высокого порядка, но единственный неакадемический язык, о котором я знаю, который может фактически полностью предотвратить эту ошибку, это Rust.

Тривиальные типовые состояния в Rust

Итак, как мы делаем это в Rust?

Простейший способ сделать это — ввести здоровые типы (ради Бога, если знаете более приличный перевод термина "sane types", сообщите мне) для наших операций над MyFile:

impl MyFile {
// `open` это единственный способ создания экземпляра `MyFile`.
pub fn open(path: &Path) -> Result { ... }

// `seek` требует экземпляр `MyFile`.
pub fn seek(&mut self, pos: Seek) -> Result { ... }

// `read_all` требует экземпляр `MyFile`.
pub fn read_all(&mut self) -> Result { ... }

// `close` принимает аргумент `self`, не ссылки `&self` или `&mut self`,
// который означает, что этот метод *перемещает* свой объект, эффективно поглощая его.
pub fn close(self) -> Result { ... }
}

impl Drop for MyFile {
// Определяем деструктор для автоматического закрытия экземпляра `MyFile`,
// если мы еще не закрыли его.
fn drop(&mut self) { ... }
}


Давайте перепишем наш изначальный пример:

fn read_contents_of_file(path: &Path) -> Result {
let mut my_file = MyFile::open(path)?;
// Обратите внимание на `?`. Это простой оператор, который просит
// компилятор проверить успешность выполнения операции.
// *Единственный* способ получить экземпляр `MyFile`
// это иметь успешно завершенным `MyFile::open`.

// В этой строке `my_file` это объект `MyFile`,
// который можно использовать.

let result = my_file.read_all()?; // Корректно.
my_file.close(); // Корректно.

// Поскольку `my_file.close()` поглощает `my_file`,
// эта переменная больше не существут.

my_file.seek(Seek::Start)?; // Ошибка, которую обнаружит компилятор.
result
}

Это работает и в более сложных случаях:

fn read_contents_of_file(path: &Path) -> Result {
// Аналогично.
let mut my_file = MyFile::open(path)?;
let result = my_file.read_all()?; // Корректно.

if are_we_happy_yet() {
my_file.close(); // Корректно.
}

// Поскольку `my_file.close()` поглощает `my_file`,
// эта переменная больше не существует, по крайней мере,
// в данной ветви выполнения кода.

my_file.seek(Seek::Start)?; // Ошибка, которую обнаружит компилятор.
result

// Если мы не закрыли `my_file`, то по выходе из блока кода,
// в котором мы его определили (в данном случае, это тело функции),
// здесь сработает его деструктор, который закроет файл.
}


Ключевой момент заключается в том, что система типов Rust обеспечивает соблюдение того факта, что переменная не может быть использована после перемещения. В нашем случае my_file.close() переместил значение, эффективно поглотив его.

Даже если бы мы попытались скрыть переменную где-то в другом месте и повторно использовать её после вызова my_file.close(), эта попытка была бы пресечена компилятором:

fn read_contents_of_file(path: &Path) -> Result {
// Аналогично.
let mut my_file = MyFile::open(path)?;
let result = my_file.read_all()?;

let mut my_file_sneaky_backup = my_file;
// Здесь мы переместили `my_file` в `my_file_sneaky_backup`.
// Поэтому мы не можем больше использовать.

my_file.close(); // Ошибка, которую обнаружит компилятор.

my_file_sneaky_backup.seek(Seek::Start)?;
result

// Если мы не закрыли `my_file`,
// здесь сработает его деструктор, который закроет файл.
}


Давайте испробуем иной путь, чтобы обмануть компилятор, для доступа к файлу после его закрытия:

fn read_contents_of_file(path: &Path) -> Result {
let my_shared_file = Rc::new(RefCell::new(MyFile::open(path)?));
// Здесь `my_shared_file` это shared указатель на мутабельный
// экземпляр `MyFile`, наподобие обычной
// ссылки в Java, C# или Python.

let result = my_shared_file.borrow_mut()
.read_all()?; // Корректно.

let my_shared_file_sneaky_backup = my_shared_file.clone();
// Мы клонировали указатель, создав второй способ доступа к `my_shared_file`.

// Давайте удостоверимся, что мы можем использовать бэкап
// и оригинальный файл:
my_shared_file_sneaky_backup.seek(Seek::Start)?; // Корректно.
my_shared_file.seek(Seek::Start)?; // Также корректно.

// Ха-ха, теперь-то мы, конечно, можем закрыть `my_shared_file`,
// а потом манипулировать `my_shared_file_sneaky_backup`,
// словно мы в Java, C# или Python!

// Да вот беда, мы не можем вызвать `my_shared_file.close()`,
// потому что укрытый `MyFile` разделен (shared),
// что означает, что никто не может *перемещать* его.
my_shared_file.close(); // Ошибка, которую обнаружит компилятор.
my_shared_file_sneaky_backup.seek(Seek::Start)?;
result

// Если мы не закрыли `my_file`, деструктор закроет его здесь.
}


И снова наши поползновения были пресечены компилятором. В самом деле, если явно не перейти в unsafe режим, мы не сможем разбить инвариант, в котором seek не может быть использован после закрытия.

Этот пример демонстрирует первый кирпич типовых состояний в Rust: типизированную операцию перемещения. Но мы разобрались только с тривиальным случаем, в котором файлы имеют лишь два состояния: открыт и закрыт.

Давайте посмотрим, как мы можем управиться с более сложными случаями.

Сложные типовые состояния

Вместо файлов, давайте теперь рассмотрим следующий (и, по общему признанию, довольно тупой) сетевой протокол:
1. Отправитель отправляет сообщение "HELLO".
2. Приемник получает сообщение "HELLO", отвечает сообщением "HELLO, YOU".
3. Отправитель получает сообщение "HELLO, YOU", отвечает случайным числом.
4. Получатель получает номер от отправителя, отвечает тем же номером.
5. Отправитель получает одинаковое количество от приемника, отвечает "BYE".
6. Получатель получает "BYE" от отправителя, отвечает "BYE, YOU".
7. Вернитесь к 1.

Любое другое сообщение игнорируется.

Мы можем спроектировать нашего Отправителя (и аналогично Получателя) так, чтобы гарантировать, что операции выполняются в правильном порядке. На данный момент мы не заботимся об идентификации корреспондента или числа.

Для этой цели мы объединим типовые состояния с другим методом, хорошо известным функциональным программистам, таким как фантомные типы.

// Серия типов нулевого размера (нет полей => нет состояния => в рантайме не существуют),
// представляющих состояние отправителя.
// Значение не имеет значения, только тип
// (отсюда и название "фантомный тип").
struct SenderReadyToSendHello;
struct SenderHasSentHello;
struct SenderHasSentNumber;
struct SenderHasReceivedNumber;

struct Sender {
/// Актуальная реализация сетевого I/O.
inner: SenderImpl;

/// Поле нулевого размера, не существующее во время выполнения.
state: S;
}

/// Следующие методы могут быть вызваны независимо от состояния.
impl Sender {
/// Порт, используемый для подключения отправителя.
fn port(&self) -> usize;

/// Закрыть отправителя, раз и навсегда.
fn close(self);
}

/// Следующий метод может быть вызван только в состоянии SenderReadyToSendHello.
impl Sender {
/// Отправить приветствие.
///
/// Этот метод поглощает отправителя в его текущем состоянии
/// и возвращает его в новом состоянии.
fn send_hello(mut self) -> Sender {
self.inner.send_message("HELLO");
Sender {
/// Перемещаем реализацию сетевого I/O.
/// Компилятор достаточно умён,
/// чтобы понять, что это не требует никакой рантайм-операции.
inner: self.inner,

/// Заменяем поле нулевого размера.
/// Эта операция будет вычищена во время выполнения.
state: SenderHasSentHello
}
}
}

/// Следующий метод может быть вызван только в состоянии SenderHasSentHello.
impl Sender {
/// Ждать, пока получатель не отправит "HELLO, YOU",
/// ответить числом.
///
/// Вернуть отправитель в состоянии `SenderHasSentNumber`
fn wait_respond_to_hello_you(mut self) -> Sender {
// ...
}

/// Если получатель отправляет "HELLO, YOU", ответить числом и
/// вернуть отправитель в состоянии `SenderHasSentNumber`.
///
/// Иначе вернуть неизмененное состояние.
fn try_respond_to_hello_you(mut self) -> Result, Self> {
// ...
}
}

/// Следующий метод может быть вызван только в состоянии SenderHasSentNumber.
impl Sender {
/// Ждать, пока получатель не отправит число, ответить "BYE".
///
/// Вернуть отправитель в состоянии `SenderReadyToSendHello`
fn wait_respond_to_hello_you(mut self) -> Sender {
// ...
}

/// Если получатель отправляет число, ответить и вернуть отправитель
/// в состоянии `SenderReadyToSendHello`.
///
/// Иначе вернуть неизмененное состояние.
fn try_respond_to_hello_you(mut self) -> Result, Self> {
// ...
}
}


Из этого дизайна очевидно, что Отправитель может следовать только такому протоколу:

1. из шага 1 (SenderReadyToSendHello) он может идти только к шагу 3;
2. из шага 3 (SenderHasSentHello) он может только либо оставаться на шаге 3 или идти к шагу 5;
3. из шага 5 (SenderHasSentNumber) он может только либо оставаться на шаге 5 или идти к шагу 1.

Любая попытка нарушить протокол будет заблокирована системой типов.

Если вам когда-нибудь понадобится работать с сетевыми протоколами, драйверами устройств, промышленными устройствами со специфичными инструкциями безопасности или OpenGL/DirectX/чем угодно еще, что потребует от вас выполнения сложных взаимодействий с "железом", вы определенно будете счастливы иметь такие гарантии!

Добро пожаловать в мир типовых состояний.

Краткое примечание: за типовые состояния

Продолжая наш сетевой пример, что если мы захотим, скажем, сохранять число, отправленное сервером, чтобы проверить, что означает ответ? Чтобы сделать это, мы можем сохранять число в SenderHasSentNumber:

struct SenderHasSentNumber {
number_sent: u32,
}


И снова, компилятор будет проверять, что код получает доступ к number_sent, только когда отправитель находится в состоянии SenderHasSentNumber.

Мы потеряем (крошечный) объем производительности, компилятор не сможем оптимизировать трансформацию Отправителя между идентичными представлениями, но вообще это того стоит.">


Здесь находятся
всего 0. За сутки здесь было 0 человек

Комментарии 2

#2   Virtuos86    

DimonVideo, на текущий момент единственное, что я не могу победить — съедается указание типа в треугольных скобках, Изначально вообще было, что парсер воспринимал, видимо, как открывающий тег зачеркнутого текста.Кстати, комментарии с основной версии не добавляются, пришлось с мобильной отправлять.Неправильно это, применять к исходному коду html-парсер.

Я принял решение отныне выкладывать код к постам на Pastebin, а в сам текст вставлять ссылку на код и его скриншот. Изображения корежить не будет.Ай да я, вот голова!


* редактировал(а) DimonVideo 20:09 26 окт 2018

0 ответить

#2   DimonVideo    

Глюки завтра исправлю, не дает отредактировать


0 ответить

Напомнить пароль

Яндекс.Метрика