Hardcore programmer
1.99K subscribers
9 photos
3 videos
22 links
Продвинутые темы из программирования и computer science. Особенности различных языков программирования. Глубокое погружение в software engineering.

Поддержать канал:
https://www.donationalerts.com/r/hardcore_programmer
Download Telegram
Media is too big
VIEW IN TELEGRAM
Продолжаю экспериментировать с форматами и записал короткое разговорное видео на пробу.

Пишите как вам такой формат, стоит ли развивать его дальше, возможно стоит что-то подкорректировать.

Если формат видео зайдет, то буду думать о создании youtube канала.
В статье про динамическую память упоминается такая идиома как RAII, давайте разберемся что это и зачем нужно.
RAII расшифровывается как Resource Acquisition Is Initialization или по-русски - получение ресурса есть инициализация. Идея данной идиомы заключается в передаче владения и управления некоторым ресурсом (выделенной динамической памятью, открытым файлом/устройством/сокетом, блокировкой мьютекса и т.д.) некоторому связанному объекту при его инициализации, а объект должен будет освободить данный ресурс при своем удалении.

Данная идиома значительно упрощает управление ресурсами, во многих случаях предотвращая проблему их утечек. Давайте посмотрим на простой пример:
auto ptr = new int;
// что-то делаем с выделенной памятью
delete ptr;

Помимо того, что программист может просто забыть написать delete, или случайно написать его дважды, может произойти еще более страшная вещь, выполнение кода может вообще не дойти до данного оператора из-за раннего return или брошенного исключения.
Немного исправим код:
auto ptr = std::make_unique<int>();
// что-то делаем с выделенной памятью

Нам больше не нужно самостоятельно управлять памятью, за нас это сделает умный указатель unique_ptr, который теперь владеет выделенной памятью. Память будет корректно освобождена даже в случае исключения.

Данная идиома является основным способом управления ресурсами в языке Rust и предпочтительным способом в языке C++. Идиома может быть использована в любом языке, в котором есть полноценные деструкторы - функции/методы вызываемые компилятором в конце жизни объекта.
Однако деструкторы есть далеко не во всех языках, а в языках со сборкой мусора они практически невозможны. Предлагаемые в некоторых из них в качестве альтернативы финализаторы здесь не помогут, их вызывает GC перед удалением объекта, а сам GC работает в плохо предсказуемые моменты времени, что делает непредсказуемым время освобождения ресурса (для памяти пойдет, а вот с файлами и блокировками мьютекса могут быть проблемы).

Однако некоторые языки все же предлагают некоторые альтернативы:
- Менеджеры контекста и оператор with в Python
- Интерфейс IDisposable и оператор using в C#
- Интерфейс AutoCloseable и оператор try (...) {} в Java
- Оператор defer в Go и в Zig

Вместо послесловия, страшный сон хаскелиста:
main :: IO ()
main = do
fileData <- readFile "file.txt"
writeFile "file.txt" $ map toUpper fileData
Типизация, типы данных - это то, с чем мы встречаемся практически в любом языке программирования. Языков без типов очень мало, в основном они низкоуровневые, вроде ассемблера, где мы оперируем абстрактными битами, байтами и машинными словами.

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

В ближайшие несколько недель я планирую заняться наполнением youtube канала. Но чтоб tg тоже не оставался без внимания, здесь будут посты про типы - один из моих любимейших разделов CS, что позволит мне писать про них достаточно быстро и непринужденно.
К теме многопоточности я вернусь чуть позже, а тема памяти уже достаточно подробно раскрыта.

А пока вопрос на затравку:
Что такое сильная (или строгая - strong) и слабая (weak) типизация?
Пишите свои мнения в комментах
Сегодня существует несколько тысяч языков программирования, каждый из которых предлагает свою систему типов. Чтобы анализировать такое многообразие систем, в чём они схожи, а чем отличаются, нужна классификация.

Так сложилось, что многие классификации систем типов оказались бинарными - такими, что рассматривают 2 противиположные характеристики того, как осуществляется типизация. При этом сами классификации не противоречат, а дополняют друг друга, можно перечислить их вместе и получить более общее описание системы типов.

В этом посте я предлагаю ознакомиться с некоторыми из таких классификаций:

Статическая vs динамическая типизация.
Рассматривает момент, в который происходит проверка типов - в compiletime или в runtime соответственно. В случае статической типизации типы являются абстракцией компилятора, у каждой сущности есть строго заданный тип и мы можем оперировать ей только в рамках указанного типа. В случае динамической типизации информация о типе хранится рядом со значениями, что позволяет сущностям изменять свой тип в процессе выполнения программы.

Явная vs неявная типизация.
Данная классификация про отношение системы типов к преобразованию данных одного типа в данные другого типа (про отношение к type casts - приведениям типов). В случае явной типизации все приведения должны быть явно выражены программистом средствами языка. В случае неявной компилятор или интерпретатор языка может сам подобрать подходящий в данном контексте каст, автоматически делая приведение.

Номинальная vs структурная.
В номинальной системе типы определяются своим объявлением, полностью идентичные типы объявленные дважды будут разными типами. В структурной системе типы определены своим содержимым, если у двух типов полностью совпадает их структура, то данные типы считаются эквивалентными.

Soundness vs comleteness.
Адекватного русского перевода для этих терминов нет. Данная классификация рассматривает гарантии предоставляемые системой типов. Soundness системы обеспечивают надежность того, что данные не выходят за ограничения своих типов, хотя иногда это приводит к тому, что полностью корректная программа может не пройти проверку типов. Completeness системы относятся к гарантиям типов более либерально, что сильно упрощает написание кода и позволяет некорректной программе пройти проверку типов. Этот подход исходит из мысли, что ничего плохого не случится, если в utf-8 строке окажется нев�лидн�й utf-8 символ.
Так же soundness характеризуют фразой "компилируется - значит работает", а completeness фразой "работает - значит должно компилироваться".

В некоторых источниках так же еще выделяют разделение на implicit и explicit отношение к типам, нередко к тому же путая его с явной/неявной типизацией (трудности перевода в действии). Речь идет про отношение языка к выводу типов, что конечно же не относится к характеристикам самой системы типов. Я рискну утверждать, что вывод типов возможен в абсолютно любой системе типов, как и ничто не мешает в абсолютно любой системе требовать задания типов explicitly.

А что же насчет сильной vs слабой типизации?
Данные термины очень размыты. На просторах интернета можно найти множество определений того, что является сильной или слабой типизацией. Проблема всех этих определений в том, что они являются определениями для любой другой бинарной классификации.
Как жить в таком хаосе? На мой взгляд хорошим подходом будет рассматривать все классификации в комплексе, включая те, которые не являются бинарными. А сами системы рассматривать в градациях, где система типов α может быть сильной по отношению к системе β и слабой по отношению к системе γ.
Статическая типизация сильнее динамической, явная сильнее неявной, номинальная сильнее структурной, soundness сильнее completeness, линейные типы сильнее аффинных и т.д.
На моем youtube вышло новое видео про асимптотическую сложность алгоритмов
Не могу не поделиться этой новостью, ибо я ждал этого несколько лет!

На прошлой неделе вышел релиз Rust 1.74.0, в котором добавили секции lints и workspace.lints в Cargo.toml
Наконец можно конфигурировать линты глобально на весь проект.

Как это было раньше?
Каноничным способом было прописывать атрибуты forbid/deny/warn/allow в корневом модуле каждого 😡 крейта или управлять этим через переменную окружения RUSTFLAGS, что было крайне не удобно.

Альтернативным способом была установка cargo cranky, который хоть и решал проблему, но плохо интегрировался с другим инструментарием Rust.

Релизы Rust выходят каждые 6 недель, но действительно значимые улучшения происходят далеко не в каждом из них.
Иногда система типов может предложить интересные решения дающие больше контроля над типизированными сущностями. В этом посте я хочу рассмотреть 2 таких решения, которые сосредоточены на управлении ресурсами посредством системы типов, а именно линейных и аффинных типах.

Линейные типы описывают объекты, которые должны быть использованы строго 1 раз. Это означает, что получив сущность линейного типа мы обязаны ей воспользоваться, а воспользовавшись однажды сделать это второй раз не выйдет. Если же нарушить эти правила, то программа не пройдет проверку типов.
Попробовать линейные типы можно в языках Haskell (пока что включив расширение компилятора) и Idris 2.

Аффинные типы описывают объекты, которые могут быть использованы не более одного раза. Отличие от линейных типов здесь в том, что у нас нет обязательства использовать сущность аффинного типа.
На аффинных типах построена концепция владения в языке Rust. Все типы в Rust являются аффинными по умолчанию, при передаче по значению используется move семантика, а корректность ее использования (невозможность использовать перемещенное значение) возлагается на проверку типов.
Однако в Rust есть 2 послабления касательно аффинных типов:
- тип может быть промаркирован трейтом Copy, что отключает для него move семантику (при передаче по значению происходит копирование) и делает такой тип обычным;
- дополнительно вводится концепция заимствования через ссылки, что позволяет обращаться к сущности многократно, правда с рядом ограничений.

В целом линейные и аффинные типы позволяют языку вводить дополнительные гарантии управления ресурсами на уровне системы типов. Линейные типы так же дают дополнительную гарантию освобождения ресурсов, тогда как в случае аффинных типов такой гарантии нет, хотя она и может быть переложена на другие механизмы языка (Rust например дает такую гарантию за счет идиомы RAII).
Хочу собрать немного статистики.
Сколько языков программирования вы знаете (на уровне не меньше чем "могу что-то написать для себя")?
Anonymous Poll
25%
1
27%
2
16%
3
7%
4
3%
5
1%
6
2%
7 или больше
12%
ни одного
8%
посмотреть результаты
Полиморфизм - какая первая ассоциация всплывает у вас, когда вы слышите это понятие? Вопрос может показаться странным, если не знать на сколько полиморфизм многогранен. Полиморфизм - он не един, его есть несколько видов, многие языки могут включать в себя сразу несколько таких видов. Хотя все эти виды все же имеют одну общую черту - связь с системой типов, а если точнее любой полиморфизм дает возможность иметь сущности, работающие не с конкретными типами, а с некоторым множеством типов.

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

Полиморфизм подтипов
Данный вид возникает, когда система типов поддерживает возможность выстраивать типы в иерархии. При этом типы стоящие ниже по иерархии по отношению к типам стоящим выше называют подтипами. Основная идея тут в том, что сущность представленная подтипом так же представлена любым его надтипом (типом стоящим по иерархии выше).
Этот вид полиморфизма часто ассоциируют с ООП (с самым распространенным на сегодня его видом, смотри видео про парадигмы если непонятно о чем речь). Иерархия здесь образуется при наследовании классов (наследник является подтипом базового класса) и при имплементации интерфейсов (имплементатор является подтипом интерфейса).
Однако иерархия может появляться и в других случаях, яркий пример тут система типов в TypeScript, в которой все типы (за исключением разве типа any) выстроены в одну огромную иерархию.

Параметрический полиморфизм
Данный вид дает возможность сущностям языка принимать типы в качестве параметров. Полиморфные сущности в данном виде имеют возможность работать с типами обобщенно. Самый распространенный представитель параметрического полиморфизма - это дженерики.
Вершиной параметрического полиморфизма являются типы высших порядков (HKT - Higher-Kinded Types), при поддержке которых возможно частичное применение параметров типа, а так же возможность передачи частичных типов в качестве параметров. Всю мощь HKT позволяет оценить язык Haskell.

Ad-hoc полиморфизм
Данный вид позволяет сущностям иметь несколько реализаций, при этом конкретная реализация определяется на основе типов в составе сущности. Самым ярким представителем тут являются перегрузки функций, когда мы имеем несколько реализаций для функции, а конкретная реализация выбирается на основе типов аргументов. Важное уточнение, перегрузки в TypeScript относятся к параметрическому полиморфизму, так как у всех перегрузок будет единая реализация. Сюда же можно отнести специализацию шаблонов в C++. Шаблоны в C++ одновременно являются представителем как параметрического так и ad-hoc полиморфизма.

Утиная типизация
Так же является видом полиморфизма, в котором полиморфные сущности определяются по наличию у их типа некоторого набора свойств (например наличия набора методов с определенными сигнатурами). Яркий представитель - интерфейсы в языке Go.
Так же условно к этому виду можно отнести все системы типов основанные на структурной типизации, условно так как чаще всего структурная типизация использует полиморфизм подтипов.

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

Простейшим способом вывода типов является задание переменной того же типа, что и тип выражения, которым данная переменная инициализирована. На этом принципе работает auto в C++ и var в Java/C#. На нём же работает вывод типов в TypeScript и некоторых других языках.
auto v = 5 * 2; // выведется int

Литералы 5 и 2 имеют тип int, operator*(int, int) возвращает int - результирующий тип всего выражения, а значит и тип переменной v, которая инициализирована этим выражением.
Такой подход позволяет так же выводить типы для дженериков/шаблонов из выражений переданных в аргументы функции.
function foo<T>(arg: T): void;
foo(10); // T выведется в number
foo('hello'); // T выведется в string

Хотя такой способ достаточно просто реализуется в компиляторе, но у него есть недостатки. Нельзя вывести тип для переменных, которые инициализируются не в момент их объявления. А в сложных системах типов такой вывод может преподносить сюрпризы:
let array = []; // выведется never[]
array.push(10); // ошибка типов


Более продвинутым способом вывода типов является алгоритм Хиндли-Милнера.
В данном способе вывод типов анализирует функцию целиком, строя на её основе систему уравнений, неизвестными в которой являются типы. Затем алгоритм за несколько шагов пытается разрешить эту систему, постепенно замещая неизвестные конкретными типами, вычисленные способом похожим на предыдущий. Вычисления останавливаются, когда больше ничего невозможно вычислить, в результате чего получаются или полностью конкретные типы или могут остаться обобщенные типы.
В случае чистых функций, которые по сути являются выражением из аргументов функции в ее возвращаемое значение оставшиеся обобщенные типы не представляют большой проблемы, мы просто получим обобщенную функцию (дженерик). Данный алгоритм применяется в языках семейства ML (Haskell, OCaml), где он может выводить даже сигнатуру для функции, пусть и зачастую в обобщенном виде.
Однако и данный алгоритм не всесилен, бывает что для некоторых переменных удается вывести лишь обобщенный тип, и если тип данной переменной не получится однозначно связать с типами в сигнатуре, то и не получится вывести обобщенную сигнатуру функции. Обычно компиляторы в этом случае требуют указать тип явно:
let v = "10".parse(); // ошибка компиляции
println!("{:?}", v);

Метод parse у строк в Rust является дженериком по возвращаемому значению (сигнатура fn parse<T>(&self): T), из контекста не понятен тип переменной v и нужно либо указать его явно либо явно задать дженерик у parse.
let v: i32 = "10".parse(); // ok
let v = "10".parse::<i32>(); // ok

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

Вывод типов - достаточно мощный инструмент современных языков, сильно упрощающий написание кода. Он так же может давать некоторые дополнительные возможности, вроде анонимных типов, которые без вывода типов были бы просто невозможны.
Сегодня анонсировали Rust 1.75
Обновиться можно будет на следующей неделе.

Главной фишкой новой версии станет поддержка синтаксиса impl Trait в качестве возвращаемого типа функций объявленных в трейтах:
trait MyTrait {
fn some_function() -> impl SomeTrait;
}

impl MyTrait for MyType {
fn some_function() -> impl SomeTrait {
todo!();
}
}

Ну а так как async fn - это по факту сахар над функциями, возвращающими impl Future, async fn так же будут доступны нативно для трейтов:
async fn f() -> i32 { 10 }
// рассахаривается в:
fn f() -> impl Future<Output = i32> { async { 10 } }
// что позволяет теперь делать так:
trait MyTrait {
async fn f();
}

Анонс конечно описывает некоторые нюансы, но развитие языка - это всегда хорошо.
В следующем году ожидается новая редакция языка (они выходят раз в 3 года), вероятно разработчики языка Rust ещё не раз порадуют нас стабилизацией полезных фич в ближайшие месяцы.

Последние пару недель у меня не хватало времени на контент, надеюсь вы простите мне такое затишье. Но всё же и я сделаю пару анонсов:
- В процессе большая статья про систему типов в TypeScript, надеюсь дописать и опубликовать на этих выходных.
- В процессе сценарий для видео, где расскажу зачем разработчику изучать несколько языков, планирую снять и выпустить его до конца года.
- 4, 5 и 6 января хочу на пробу провести стримы на ютубе, ориентируйтесь на старт в 19:00 мск, будем общаться + буду кодить небольшие штуки с нуля в прямом эфире, а если на первых двух стримах будет хотя бы по 50 зрителей, то на третьем возьму новый для себя ЯП и покажу как можно быстро разобраться в новом языке.
Где-то Новый Год уже наступил, а где-то только на подходе.
В новом году я желаю своим подписчикам счастья, благополучия, мира во всем мире, высоких зарплат и карьерных достижений, прибыльных инвестиционных портфелей!
Продолжайте развиваться и будьте себе на уме.
🥂🍾☃️❄️🎉
Сегодня в 19:00 мск будет ещё один стрим (запись останется, ссылку выложу примерно за пол часа).
В первой части поговорим про то как изучать языки программирования и зачем это нужно.
Во второй части попробую освоить новый для себя язык zig и написать на нём небольшую программку.
В стриме, где я разбирался с языком zig, я упомянул такой термин, как алгебраический тип данных (algebraic data type, ADT). Давайте разберемся, что это такое и зачем нужно.

ADT появились в функциональных языках, таких как Haskell или OCaml, но встречаются и в языках ориентированных на другие парадигмы, например в Rust, Zig и TypeScript.
ADT представляют собой тип-сумму, объединяющий в себе данные разных типов по принципу ИЛИ, что позволяет хранить в одной переменной данные разных типов, но при этом типобезопасно их получать. Основным способом извлечения данных из ADT является pattern matching (сопоставление с образцом), который позволяет проверить, какой вариант данных сейчас находится в переменной с ADT типом.

Давайте посмотрим примеры на различных языках. Пусть у нас есть типы Cat и Dog, а мы хотим объединить их в ADT типе Pet.
На Haskell объявление будет выглядеть так:
data Pet = PetCat Cat | PetDog Dog

А на Rust так:
enum Pet {
Cat(Cat),
Dog(Dog),
}

А вот так на TypeScript:
type Pet = {
kind: 'Cat';
cat: Cat;
} | {
kind: 'Dog';
dog: Dog;
};


Имея переменную такого типа Pet мы можем однозначно определять, что внутри - Cat или Dog и выполнять в зависимости от этого разную логику:
f :: Pet -> IO ()
f (PetCat _) = print "Cat"
f (PetDog _) = print "Dog"

match pet {
Pet::Cat(cat) => println!("Cat: {:?}", cat),
Pet::Dog(dog) => println!("Dog: {:?}", dog),
}

switch (pet.kind) {
case 'Cat':
console.log('Cat', pet.cat);
break;
case 'Dog':
console.log('Dog', pet.dog);
break;
}


ADT - это достаточно мощный инструмент в тех системах типов и языках, где он есть, позволяющий оперировать данными разных типов в рамках одной сущности, при этом сохраняя безопасность с точки зрения типов.