Подтвердить что ты не робот

Какие типы ошибок могут вызывать Haskell во время компиляции, что Java не может?

Я только начинаю изучать Haskell и продолжать видеть ссылки на его мощную систему типов. Я вижу много примеров, в которых вывод гораздо более мощный, чем Javas, но также подразумевает, что он может уловить больше ошибок во время компиляции из-за своей системы высшего типа. Итак, мне интересно, можно ли объяснить, какие типы ошибок Haskell может уловить во время компиляции, которые Java не может.

4b9b3361

Ответ 1

Говоря, что система типа Haskell может ловить больше ошибок, чем Java, немного вводит в заблуждение. Позвольте распаковать это немного.

Статически типизированный

Java и Haskell являются одновременно статически типизированными языками. Под этим я подразумеваю, что тип данного выражения на языке известен во время компиляции. Это имеет ряд преимуществ, как для Java, так и для Haskell, а именно позволяет компилятору проверить, что выражения являются "нормальными", для некоторого разумного определения разумности.

Да, Java допускает определенные выражения "смешанного типа", такие как "abc" + 2, которые некоторые могут утверждать, это небезопасно или плохо, но это субъективный выбор. В конце концов, это просто особенность, предлагаемая языком Java, к лучшему или к худшему.

Неизменность

Чтобы узнать, как можно утверждать, что код Haskell меньше подвержен ошибкам, чем код Java (или C, С++ и т.д.), вы должны учитывать систему типов в отношении неизменности языка. В чистом (нормальном) коде Haskell побочных эффектов нет. То есть, никакая ценность в программе после ее создания никогда не изменится. Когда мы что-то вычисляем, мы создаем новый результат из старого результата, но мы не модифицируем старое значение. Это, как оказалось, имеет некоторые очень удобные последствия с точки зрения безопасности. Когда мы пишем код, мы можем быть уверены, что ничто другое в программе не повлияет на нашу функцию. Побочные эффекты, как выясняется, являются причиной многих ошибок программирования. Примером может быть общий указатель на C, который освобождается в одной функции, а затем доступен в другом, что приводит к сбою. Или переменная, которая имеет значение null в Java,

String foo = "bar";
foo = null;
Char c = foo.charAt(0); # Error!

Это не может случиться в нормальном коде Haskell, потому что foo после определения не может изменить. Это означает, что он не может быть установлен на null.

Введите систему типов

Теперь вы, вероятно, задаетесь вопросом, как система типов играет во всем этом, вот что вы просили в конце концов. Ну, так же хорошо, как неизменность, оказывается, очень мало интересной работы, которую вы можете сделать без мутации. Чтение из файла? Мутация. Запись на диск? Мутация. Разговор с веб-сервером? Мутация. Так что же нам делать? Чтобы решить эту проблему, Haskell использует свою систему типов для инкапсуляции мутации в тип, называемый IO Monad. Например, для чтения из файла эта функция может использоваться,

readFile :: FilePath -> IO String

IO Monad

Обратите внимание, что тип результата не является String, это IO String. То, что это означает, в терминах laymans, заключается в том, что результат вводит IO (побочные эффекты) в программу. В хорошо сформированной программе IO будет проходить только внутри монады IO, что позволит нам увидеть очень четко, где могут возникать побочные эффекты. Это свойство применяется системой типов. Дальнейшие типы IO a могут давать только свои результаты, которые являются побочными эффектами, внутри функции main программы. Итак, теперь мы очень аккуратно и прекрасно изолированы от опасных побочных эффектов для контролируемой части программы. Когда вы получите результат IO String, все может произойти, но по крайней мере это не может произойти нигде, только в функции main и только в результате типов IO a.

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

strReplicate :: IO String
strReplicate =
  readFile "somefile that doesn't exist" >>= return . concat . replicate 2

Эта функция считывает данные из файла, дублирует этот ввод и добавляет дублированный вход в конец исходного ввода. Поэтому, если в файле были символы abc, это создало бы String с содержимым abcabc. Вы можете вызвать эту функцию в любом месте вашего кода, но Haskell будет только пытаться прочитать файл, если выражение найдено в функции main, потому что это экземпляр IO Monad. Таким образом,

main :: IO ()
main =
  strReplicate >>=
  putStrLn

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

В IO и Monads есть намного больше, чем здесь, но это, вероятно, выходит за рамки вашего вопроса.

Тип вывода

Теперь есть еще один аспект этого. Вывод типа

Haskell использует очень продвинутую систему ввода типов, который позволяет вам писать код, который статически типизирован без необходимости писать аннотацию типа, например String foo в Java. GHC может вывести тип почти любого выражения, даже очень сложных.

То, что это означает для нашего обсуждения безопасности, состоит в том, что везде в программе используется экземпляр IO a, система типов будет следить за тем, чтобы он не мог использоваться для создания неожиданного побочного эффекта. Вы не можете направить его на String и просто получить результат, когда/когда захотите. Вы должны явно ввести побочный эффект в функции main.

Безопасность статического ввода с простотой динамического ввода

Система вывода типов имеет и другие хорошие свойства. Часто люди наслаждаются скриптовыми языками, потому что им не нужно писать все эти шаблоны для типов, которые они должны были бы делать на Java или C. Это связано с тем, что языки сценариев динамически типизированы или тип выражения вычисляется только как выражение управляется интерпретатором. Это делает эти языки более склонными к ошибкам, потому что вы не будете знать, есть ли у вас плохое выражение, пока вы не запустите код. Например, вы можете сказать что-то подобное в Python.

def foo(x,y):
  return x + y

Проблема заключается в том, что x и y может быть чем угодно. Так что это было бы хорошо,

foo(1,2) -> 3

Но это приведет к ошибке,

foo(1,[]) -> Error

И теперь у нас есть способ проверить, что это недопустимо, пока он не будет запущен.

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

Заключение

Надеюсь, это поможет осветить, как Haskell держит вас в безопасности. А что касается Java, вы можете сказать, что в Java вы должны работать против системы типов для написания кода, но в Haskell система типов работает для вас.

Ответ 2

Тип Casts

Одно из отличий заключается в том, что Java позволяет применять динамические типы, такие как (например, следующий глупый пример):

class A { ... }
static String needsA(A a) { ... }

Object o = new A();
needsA((A) o);

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

В Haskell существует (грубо) отсутствие подтипирования, следовательно, нет типов. Наиболее близкой особенностью для отливок является (нечасто используемая) библиотека Data.Typeable, как показано ниже

foo :: Typeable t => t -> String
foo x = case cast x :: A of          -- here x is of type t
        Just y  -> needsA y          -- here y is of type A
        Nothing -> "x was not an A"

что примерно соответствует

String foo(Object x) {
   if (x instanceof A) {
      A y = (A) x;
      return needsA(y);
   } else {
      return "x was not an A";
   }
}

Основное различие между Haskell и Java заключается в том, что в Java у нас есть отдельная проверка времени выполнения (instanceof) и литье ((A)). Это может привести к ошибкам во время выполнения, если проверки не гарантируют, что приводы будут успешными.

Я помню, что приводы были большой проблемой на Java до того, как были введены дженерики, так как, например, используя коллекции, заставили вас выполнить много отливок. С generics система типов Java значительно улучшилась, и в настоящее время в Java накладывается гораздо меньше, так как они менее необходимы.

В ролях и дженериках

Вспомните, что общие типы стираются во время выполнения в Java, поэтому код, например

if (x instanceof ArrayList<Integer>) {
  ArrayList<Integer> y = (ArrayList<Integer>) x;
}

не работает. Проверка не может быть выполнена полностью, так как мы не можем проверить параметр ArrayList. Кроме того, из-за этого стирания, если я правильно помню, приведение может преуспеть, даже если x является другим ArrayList<String>, но только для возникновения ошибок типа времени выполнения, даже если броски не отображаются в коде.

Механизм Data.Typeable Haskell не стирает типы во время выполнения.

Более мощные типы

Haskell GADT и (Coq, Agda,...) зависимые типы расширяют обычную проверку статического типа для обеспечения еще более сильных свойств кода во время компиляции.

Рассмотрим, например, функция zip Haskell. Вот пример:

zip (+) [1,2,3] [10,20,30] = [1+10,2+20,3+30] = [11,22,33]

Это применимо (+) в потоке в двух списках. Его определение:

-- for the sake of illustration, let use lists of integers here
zip :: (Int -> Int -> Int) -> [Int] -> [Int] -> [Int]
zip f []     _      = []
zip f _      []     = []
zip f (x:xs) (y:ys) = f x y : zip xs ys

Что происходит, если мы передаем списки разной длины?

zip (+) [1,2,3] [10,20,30,40,50,60,70] = [1+10,2+20,3+30] = [11,22,33]

Чем длиннее, тем тише усекается. Это может быть неожиданное поведение. Можно переопределить zip как:

zip :: (Int -> Int -> Int) -> [Int] -> [Int] -> [Int]
zip f []     []     = []
zip f (x:xs) (y:ys) = f x y : zip xs ys
zip f _      _      = error "zip: uneven lenghts"

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

data Z       -- zero
data S n     -- successor 
-- the type S (S (S Z)) is used to represent the number 3 at the type level    

-- List n a is a list of a having exactly length n
data List n a where
   Nil :: List Z a
   Cons :: a -> List n a -> List (S n) a

-- The two arguments of zip are required to have the same length n.
-- The result is long n elements as well.
zip' :: (Int -> Int -> Int) -> List n Int -> List n Int -> List n Int
zip' f Nil         Nil         = Nil
zip' f (Cons x xs) (Cons y ys) = Cons (f x y) (zip' f xs ys)

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

В Java вы можете кодировать длины списка в типе, используя тот же трюк:

class Z {}
class S<N> {}
class List<N,A> { ... }

static <A> List<Z,A> nil() {...}
static <A,N> List<S<N>,A> cons(A x, List<N,A> list) {...}

static <N,A> List<N,A> zip(List<N,A> list1, List<N,A> list2) {
   ...
}

но, насколько я вижу, код zip не может получить доступ к хвостам двух списков и сделать их доступными как две переменные того же типа List<M,A>, где M интуитивно N-1, Интуитивно доступ к двум хвостам теряет информацию о типе, поскольку мы больше не знаем, что они имеют четную длину. Чтобы выполнить рекурсивный вызов, понадобится бросок.

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

Ответ 3

В Haskell есть несколько вещей, которые делают его "более безопасным", чем Java. Система типов является одним из очевидных.

Без типов. Java и аналогичные языки OO позволяют создавать объекты одного типа для другого. Если вы не можете убедить систему типов позволить вам делать то, что вы пытаетесь сделать, вы всегда можете просто бросить все на Object (хотя большинство программистов сразу признают это как чистое зло). Беда в том, что теперь вы находитесь в сфере проверки типов во время выполнения, точно так же, как на динамически типизированном языке. Хаскелл не позволяет вам делать такие вещи. (Если вы явно не сойдете с пути, чтобы получить его, и почти никто не делает этого.)

Используемые дженерики. Дженерики доступны в Java, С#, Eiffel и некоторых других языках OO. Но в Haskell они действительно работают. В Java и С#, пытаясь написать общий код, почти всегда приводит к неясным сообщениям компилятора о "о, вы не можете использовать его таким образом". В Haskell общий код легко. Вы можете написать это случайно! И он работает точно так, как вы ожидали.

Удобство. Вы можете делать что-то в Haskell, что было бы слишком много усилий на Java. Например, настройте различные типы для сырых пользовательских вводных стилей, дезинфицированных пользователем. Вы можете полностью сделать это на Java. Но вы этого не сделаете. Это слишком много шаблонов. Вы только потрудитесь делать это, если это абсолютно важно для вашего приложения. Но в Haskell это всего лишь несколько строк кода. Это легко. Люди делают это для удовольствия!

Магия. [У меня нет более технического термина для этого.] Иногда сигнатура типа функции позволяет вам узнать, что делает функция. Я не имею в виду, что вы можете понять, что делает функция, я имею в виду, что существует только одна возможная функция, которую может выполнять функция с этим типом, или она не будет компилироваться. Это чрезвычайно мощная собственность. Программисты Haskell иногда говорят "когда компилируются, как правило, без ошибок" и, вероятно, прямым результатом этого.

Не будучи строго свойствами системы типов, я могу также упомянуть:

  • Явный ввод-вывод. Сигнатура типа функции указывает, выполняет ли она какой-либо ввод-вывод или нет. Функции, не выполняющие операции ввода-вывода, являются потокобезопасными и очень легко проверяются.

  • Явный null. Данные не могут быть нулевыми, если подпись типа не говорит об этом. Вы должны явно проверить значение null при использовании данных. Если вы "забудете", подписи типов не будут совпадать.

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

Сказав все это, программы Java, как правило, умирают с исключениями нулевого указателя или индекса массива; Программы Haskell имеют тенденцию умирать с исключениями, такими как печально известная "голова []".

Ответ 4

Для очень простого примера, хотя это допустимо в Java:

public class HelloWorld {

    public static void main(String[] args) {
        int x = 4;
        String name = "four";

        String test = name + x;
        System.out.println(test);
    }

}

То же самое приведет к ошибке компиляции в Haskell:

fourExample = "four" + 4

В Haskell нет неявного литья типов, который помогает предотвратить глупые ошибки, такие как "four" + 4. Вы должны сказать это явно, что вы хотите преобразовать его в String:

fourExample = "four" ++ show 4